From 7ee8998700ccbf714ccf2801b993f5eb40465f84 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Tue, 25 Jun 2024 12:12:28 -0500 Subject: [PATCH 01/37] add a cmd as an experimental testbed for solana --- cmd/solana/gateway.json | 420 ++++++++++++++++++++++++++++++++++++++++ cmd/solana/main.go | 238 +++++++++++++++++++++++ cmd/solana/types.go | 67 +++++++ go.mod | 19 +- go.sum | 50 ++++- 5 files changed, 790 insertions(+), 4 deletions(-) create mode 100644 cmd/solana/gateway.json create mode 100644 cmd/solana/main.go create mode 100644 cmd/solana/types.go diff --git a/cmd/solana/gateway.json b/cmd/solana/gateway.json new file mode 100644 index 0000000000..8747c2ca0f --- /dev/null +++ b/cmd/solana/gateway.json @@ -0,0 +1,420 @@ +{ + "address": "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", + "metadata": { + "name": "gateway", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "deposit", + "discriminator": [ + 242, + 35, + 198, + 137, + 82, + 225, + 242, + 182 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "memo", + "type": "bytes" + } + ] + }, + { + "name": "deposit_spl_token", + "discriminator": [ + 86, + 172, + 212, + 121, + 63, + 233, + 96, + 144 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "from", + "writable": true + }, + { + "name": "to", + "writable": true + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "memo", + "type": "bytes" + } + ] + }, + { + "name": "initialize", + "discriminator": [ + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "tss_address", + "type": { + "array": [ + "u8", + 20 + ] + } + } + ] + }, + { + "name": "update_tss", + "discriminator": [ + 227, + 136, + 3, + 242, + 177, + 168, + 10, + 160 + ], + "accounts": [ + { + "name": "pda", + "writable": true + }, + { + "name": "signer", + "writable": true, + "signer": true + } + ], + "args": [ + { + "name": "tss_address", + "type": { + "array": [ + "u8", + 20 + ] + } + } + ] + }, + { + "name": "withdraw", + "discriminator": [ + 183, + 18, + 70, + 156, + 148, + 109, + 161, + 34 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true + }, + { + "name": "to", + "writable": true + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "signature", + "type": { + "array": [ + "u8", + 64 + ] + } + }, + { + "name": "recovery_id", + "type": "u8" + }, + { + "name": "message_hash", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "nonce", + "type": "u64" + } + ] + }, + { + "name": "withdraw_spl_token", + "discriminator": [ + 219, + 156, + 234, + 11, + 89, + 235, + 246, + 32 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "from", + "writable": true + }, + { + "name": "to", + "writable": true + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "signature", + "type": { + "array": [ + "u8", + 64 + ] + } + }, + { + "name": "recovery_id", + "type": "u8" + }, + { + "name": "message_hash", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "nonce", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "Pda", + "discriminator": [ + 169, + 245, + 0, + 205, + 225, + 36, + 43, + 94 + ] + } + ], + "errors": [ + { + "code": 6000, + "name": "SignerIsNotAuthority", + "msg": "SignerIsNotAuthority" + }, + { + "code": 6001, + "name": "InsufficientPoints", + "msg": "InsufficientPoints" + }, + { + "code": 6002, + "name": "NonceMismatch", + "msg": "NonceMismatch" + }, + { + "code": 6003, + "name": "TSSAuthenticationFailed", + "msg": "TSSAuthenticationFailed" + }, + { + "code": 6004, + "name": "DepositToAddressMismatch", + "msg": "DepositToAddressMismatch" + }, + { + "code": 6005, + "name": "MessageHashMismatch", + "msg": "MessageHashMismatch" + }, + { + "code": 6006, + "name": "MemoLengthExceeded", + "msg": "MemoLengthExceeded" + }, + { + "code": 6007, + "name": "MemoLengthTooShort", + "msg": "MemoLengthTooShort" + } + ], + "types": [ + { + "name": "Pda", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nonce", + "type": "u64" + }, + { + "name": "tss_address", + "type": { + "array": [ + "u8", + 20 + ] + } + }, + { + "name": "authority", + "type": "pubkey" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/cmd/solana/main.go b/cmd/solana/main.go new file mode 100644 index 0000000000..d1b3b0be28 --- /dev/null +++ b/cmd/solana/main.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "log" + "os" + + "github.com/davecgh/go-spew/spew" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" +) + +const ( + PYTH_PROGRAM_DEVNET = "gSbePebfvPy7tRqimPoVecS2UsBvYv46ynrzWocc92s" // this program has many many txs +) + +//go:embed gateway.json +var GatewayIDLJSON []byte + +func main() { + // devnet RPC + client := rpc.New("https://solana-devnet.g.allthatnode.com/archive/json_rpc/842c667c947e42e2a9995ac2ec75026d") + + limit := 10 + out, err := client.GetSignaturesForAddressWithOpts( + context.TODO(), + solana.MustPublicKeyFromBase58(PYTH_PROGRAM_DEVNET), + &rpc.GetSignaturesForAddressOpts{ + Limit: &limit, + Before: solana.MustSignatureFromBase58("5pLBywq74Nc6jYrWUqn9KjnYXHbQEY2UPkhWefZF5u4NYaUvEwz1Cirqaym9wDeHNAjiQwuLBfrdhXo8uFQA45jL"), + Until: solana.MustSignatureFromBase58("2coX9CckSmJWeHVqJNANeD7m4J7pctpSomxMon3h36droxCVB3JDbLyWQKMjnf85ntuFGxMLySykEMaRd5MDw35e"), + }, + ) + + if err != nil { + panic(err) + } + fmt.Printf("len(out) = %d\n", len(out)) + //spew.Dump(out) + for _, sig := range out { + fmt.Printf("%s %d %v\n", sig.Signature, sig.Slot, sig.Err == nil) + } + + { + bn, _ := client.GetFirstAvailableBlock(context.TODO()) + fmt.Printf("first available bn = %d\n", bn) + cutoffTimestamp, _ := client.GetBlockTime(context.TODO(), bn) + fmt.Printf("cutoffTimestamp = %s\n", cutoffTimestamp.Time()) + block, _ := client.GetBlock(context.TODO(), bn) + //spew.Dump(block) + fmt.Printf("block time %s, block height %d\n", block.BlockTime.Time(), *block.BlockHeight) + fmt.Printf("block #%d\n", len(block.Transactions)) + //first_tx := block.Signatures[0] + //spew.Dump(first_tx) + } + + { + // Parsing a Deposit Instruction + // devnet tx: deposit with memo + // https://solana.fm/tx/51746triQeve21zP1bcVEPvvsoXt94B57TU5exBvoy938bhGCfzBtsvKJbLpS1zRc2dmb3S3HBHnhTfbtKCBpmqg + const DEPOSIT_TX = "51746triQeve21zP1bcVEPvvsoXt94B57TU5exBvoy938bhGCfzBtsvKJbLpS1zRc2dmb3S3HBHnhTfbtKCBpmqg" + + tx, err := client.GetTransaction( + context.TODO(), + solana.MustSignatureFromBase58(DEPOSIT_TX), + &rpc.GetTransactionOpts{}) + if err != nil { + log.Fatalf("Error getting transaction: %v", err) + } + fmt.Printf("tx status: %v", tx.Meta.Err == nil) + //spew.Dump(tx) + type DepositInstructionParams struct { + Discriminator [8]byte + Amount uint64 + Memo []byte + } + //hexString := "f223c68952e1f2b6390500000000000014000000dead000000000000000042069420694206942069" + // Decode hex string to byte slice + //data, _ := hex.DecodeString(hexString) + transaction, _ := tx.Transaction.GetTransaction() + instruction := transaction.Message.Instructions[0] + data := instruction.Data + pk, _ := transaction.Message.Program(instruction.ProgramIDIndex) + fmt.Printf("Program ID: %s\n", pk) + var inst DepositInstructionParams + err = borsh.Deserialize(&inst, data) + if err != nil { + log.Fatalf("Error deserializing: %v", err) + } + fmt.Printf("Discriminator: %016x\n", inst.Discriminator) + fmt.Printf("U64 Parameter: %d\n", inst.Amount) + fmt.Printf("Vec (%d): %x\n", len(inst.Memo), inst.Memo) + } + + { + var idl IDL + err := json.Unmarshal(GatewayIDLJSON, &idl) + if err != nil { + panic(err) + } + //spew.Dump(idl) + } + + { + // explore failed transaction + //https://explorer.solana.com/tx/2LbBdmCkuVyQhHAvsZhZ1HLdH12jQbHY7brwH6xUBsZKKPuV8fomyz1Qh9CaCZSqo8FNefaR8ir7ngo7H3H2VfWv + tx_sig := solana.MustSignatureFromBase58("2LbBdmCkuVyQhHAvsZhZ1HLdH12jQbHY7brwH6xUBsZKKPuV8fomyz1Qh9CaCZSqo8FNefaR8ir7ngo7H3H2VfWv") + client2 := rpc.New("https://solana-mainnet.g.allthatnode.com/archive/json_rpc/842c667c947e42e2a9995ac2ec75026d") + tx, err := client2.GetTransaction( + context.TODO(), + tx_sig, + &rpc.GetTransactionOpts{}) + if err != nil { + log.Fatalf("Error getting transaction: %v", err) + } + fmt.Printf("tx successful?: %v\n", tx.Meta.Err == nil) + spew.Dump(tx) + } + + { + //system.NewTransferInstruction() + //// build a deposit transaction + //solana.NewTransaction( + // solana.Instruction{ + // + // } + // ) + pk := os.Getenv("SOLANA_WALLET_PK") + if pk == "" { + log.Fatal("SOLANA_WALLET_PK must be set (base58 encoded private key)") + } + + privkey, err := solana.PrivateKeyFromBase58(pk) + if err != nil { + log.Fatalf("Error getting private key: %v", err) + } + + fmt.Println("account public key:", privkey.PublicKey()) + bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) + if err != nil { + log.Fatalf("Error getting balance: %v", err) + } + fmt.Println("account balance in SOL ", float64(bal.Value)/1e9) + + // building the transaction + recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) + if err != nil { + panic(err) + } + fmt.Println("recent blockhash:", recent.Value.Blockhash) + + programId := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") + seed := []byte("meta") + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programId) + if err != nil { + panic(err) + } + fmt.Printf("computed pda: %s, bump %d\n", pdaComputed, bump) + + //pdaAccount := solana.MustPublicKeyFromBase58("4hA43LCh2Utef8EwCyWwYmWBoSeNq6RS2HdoLkWGm5z5") + var inst solana.GenericInstruction + accountSlice := []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + accountSlice = append(accountSlice, solana.Meta(programId)) + inst.ProgID = programId + inst.AccountValues = accountSlice + + type DepositInstructionParams struct { + Discriminator [8]byte + Amount uint64 + Memo []byte + } + + inst.DataBytes, err = borsh.Serialize(DepositInstructionParams{ + Discriminator: [8]byte{0xf2, 0x23, 0xc6, 0x89, 0x52, 0xe1, 0xf2, 0xb6}, + Amount: 1338, + Memo: []byte("hello this is a good memo for you to enjoy"), + }) + //inst.DataBytes, err = hex.DecodeString("f223c68952e1f2b6390500000000000014000000dead000000000000000042069420694206942069") + if err != nil { + panic(err) + } + + tx, err := solana.NewTransaction( + []solana.Instruction{&inst}, + recent.Value.Blockhash, + solana.TransactionPayer(privkey.PublicKey()), + ) + if err != nil { + panic(err) + } + _, err = tx.Sign( + func(key solana.PublicKey) *solana.PrivateKey { + if privkey.PublicKey().Equals(key) { + return &privkey + } + return nil + }, + ) + if err != nil { + panic(fmt.Errorf("unable to sign transaction: %w", err)) + } + + spew.Dump(tx) + //wsClient, err := ws.Connect(context.Background(), rpc.DevNet_WS) + //if err != nil { + // panic(err) + //} + //sig, err := confirm.SendAndConfirmTransaction( + // context.TODO(), + // client, + // wsClient, + // tx, + //) + // tx: 33cVywTwufSy5NsNSnJS87wmkPwVAr9iiJqxAhhny9pazxWpiH6L24c6ruVnSjctcGasyt2ngnrtx3TqK6KU6x6j + + //sig, err := client.SendTransactionWithOpts( + // context.TODO(), + // tx, + // rpc.TransactionOpts{}, + //) + // broadcast success! see + // https://solana.fm/tx/43hXUywVouKeG5V98mjPysPWG9eKyKo6XDVHuoQs5YP1gJfa5z2UtU6hjJGgscrWzmYbhbqNW2hykvV6HYfBXATD + + //if err != nil { + // panic(err) + //} + //spew.Dump(sig) + + } + +} diff --git a/cmd/solana/types.go b/cmd/solana/types.go new file mode 100644 index 0000000000..1398be9962 --- /dev/null +++ b/cmd/solana/types.go @@ -0,0 +1,67 @@ +package main + +type IDL struct { + Address string `json:"address"` + Metadata Metadata `json:"metadata"` + Instructions []Instruction `json:"instructions"` + Accounts []Account `json:"accounts"` + Errors []Error `json:"errors"` + Types []Type `json:"types"` +} + +type Metadata struct { + Name string `json:"name"` + Version string `json:"version"` + Spec string `json:"spec"` + Description string `json:"description"` +} + +type Instruction struct { + Name string `json:"name"` + Discriminator []byte `json:"discriminator"` + Accounts []Account `json:"accounts"` + Args []Arg `json:"args"` +} + +type Account struct { + Name string `json:"name"` + Writable bool `json:"writable,omitempty"` + Signer bool `json:"signer,omitempty"` + Address string `json:"address,omitempty"` + PDA *PDA `json:"pda,omitempty"` +} + +type PDA struct { + Seeds []Seed `json:"seeds"` +} + +type Seed struct { + Kind string `json:"kind"` + Value []byte `json:"value,omitempty"` +} + +type Arg struct { + Name string `json:"name"` + Type interface{} `json:"type"` +} + +type Error struct { + Code int `json:"code"` + Name string `json:"name"` + Msg string `json:"msg"` +} + +type Type struct { + Name string `json:"name"` + Type TypeField `json:"type"` +} + +type TypeField struct { + Kind string `json:"kind"` + Fields []Field `json:"fields"` +} + +type Field struct { + Name string `json:"name"` + Type interface{} `json:"type"` +} diff --git a/go.mod b/go.mod index 60a7598f16..03290a2c10 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/cosmos/cosmos-sdk v0.47.10 github.com/cosmos/gogoproto v1.4.10 github.com/ethereum/go-ethereum v1.10.26 + github.com/gagliardetto/solana-go v1.10.0 github.com/gogo/protobuf v1.3.3 // indirect github.com/golang/protobuf v1.5.3 github.com/gorilla/mux v1.8.0 @@ -22,7 +23,6 @@ require ( google.golang.org/genproto v0.0.0-20240102182953-50ed04b92917 // indirect google.golang.org/grpc v1.60.1 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c - ) require ( @@ -65,6 +65,7 @@ require ( github.com/golang/mock v1.6.0 github.com/huandu/skiplist v1.2.0 github.com/nanmu42/etherscan-api v1.10.0 + github.com/near/borsh-go v0.3.1 github.com/onrik/ethrpc v1.2.0 go.nhat.io/grpcmock v0.25.0 ) @@ -77,7 +78,10 @@ require ( github.com/DataDog/zstd v1.5.0 // indirect github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect + github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect + github.com/blendle/zapdriver v1.3.1 // indirect github.com/bool64/shared v0.1.5 // indirect + github.com/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 @@ -91,6 +95,8 @@ require ( github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gagliardetto/binary v0.8.0 // indirect + github.com/gagliardetto/treeout v0.1.4 // indirect github.com/getsentry/sentry-go v0.23.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -98,11 +104,17 @@ 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 + github.com/json-iterator/go v1.1.12 // indirect github.com/libp2p/go-yamux/v4 v4.0.0 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/miekg/pkcs11 v1.1.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect github.com/onsi/ginkgo/v2 v2.9.7 // indirect github.com/prometheus/tsdb v0.7.1 // indirect github.com/quic-go/qpack v0.4.0 // indirect @@ -113,6 +125,7 @@ require ( github.com/rjeczalik/notify v0.9.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect + github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect github.com/swaggest/assertjson v1.9.0 // indirect github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c // indirect github.com/thales-e-security/pool v0.0.2 // indirect @@ -123,6 +136,7 @@ require ( github.com/yudai/gojsondiff v1.0.0 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect go.etcd.io/bbolt v1.3.7 // indirect + go.mongodb.org/mongo-driver v1.11.0 // indirect go.nhat.io/matcher/v2 v2.0.0 // indirect go.nhat.io/wait v0.1.0 // indirect go.opentelemetry.io/otel v1.19.0 // indirect @@ -130,6 +144,7 @@ require ( go.opentelemetry.io/otel/trace v1.19.0 // indirect go.uber.org/dig v1.17.0 // indirect go.uber.org/fx v1.19.2 // indirect + go.uber.org/ratelimit v0.2.0 // indirect golang.org/x/time v0.5.0 // indirect gonum.org/v1/gonum v0.13.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240108191215-35c7eff3a6b1 // indirect @@ -204,7 +219,7 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/orderedcode v0.0.1 // indirect - github.com/google/uuid v1.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/gorilla/handlers v1.5.1 // indirect diff --git a/go.sum b/go.sum index a5bbf02ee3..032dd0efda 100644 --- a/go.sum +++ b/go.sum @@ -224,6 +224,7 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN github.com/99designs/keyring v1.1.6/go.mod h1:16e0ds7LGQQcT59QqkTg72Hh5ShM51Byv5PEmW6uoRU= github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o= github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= +github.com/AlekSi/pointer v1.1.0 h1:SSDMPcXD9jSl8FPy9cRzoRaMJtm9g9ggGTxecRUbQoI= github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= @@ -289,6 +290,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc= +github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI= +github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -340,6 +343,8 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= +github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/bool64/dev v0.2.29 h1:x+syGyh+0eWtOzQ1ItvLzOGIWyNWnyjXpHIcpF2HvL4= @@ -374,6 +379,8 @@ 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= @@ -640,6 +647,12 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= +github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= +github.com/gagliardetto/solana-go v1.10.0 h1:lDuHGC+XLxw9j8fCHBZM9tv4trI0PVhev1m9NAMaIdM= +github.com/gagliardetto/solana-go v1.10.0/go.mod h1:afBEcIRrDLJst3lvAahTr63m6W2Ns6dajZxe2irF7Jg= +github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= +github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= @@ -845,8 +858,8 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= @@ -876,6 +889,8 @@ 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= @@ -1055,6 +1070,7 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jsternberg/zap-logfmt v1.0.0/go.mod h1:uvPs/4X51zdkcm5jXl5SYoN+4RK21K8mysFmDaM/h+o= @@ -1084,8 +1100,10 @@ github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0 github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.9.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= @@ -1155,6 +1173,8 @@ github.com/libp2p/go-yamux/v4 v4.0.0 h1:+Y80dV2Yx/kv7Y7JKu0LECyVdMXm1VUoko+VQ9rB github.com/libp2p/go-yamux/v4 v4.0.0/go.mod h1:NWjl8ZTLOGlozrXSOZ/HlfG++39iKNnM5wwmtQP1YB4= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lucasjones/reggen v0.0.0-20180717132126-cdb49ff09d77/go.mod h1:5ELEyG+X8f+meRWHuqUOewBOhvHkl7M76pdGEansxW4= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= @@ -1259,7 +1279,11 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= @@ -1308,6 +1332,8 @@ github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7 github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/near/borsh-go v0.3.1 h1:ukNbhJlPKxfua0/nIuMZhggSU8zvtRP/VyC25LLqPUA= +github.com/near/borsh-go v0.3.1/go.mod h1:NeMochZp7jN/pYFuxLkrZtmLqbADmnp/y1+/dL+AsyQ= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/neilotoole/errgroup v0.1.5/go.mod h1:Q2nLGf+594h0CLBs/Mbg6qOr7GtqDK7C2S41udRnToE= @@ -1520,6 +1546,8 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF github.com/shirou/gopsutil v2.20.5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= @@ -1599,6 +1627,8 @@ github.com/steakknife/hamming v0.0.0-20180906055917-c99c65617cd3/go.mod h1:hpGUW github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -1643,6 +1673,8 @@ github.com/tendermint/tendermint v0.34.12/go.mod h1:aeHL7alPh4uTBIJQ8mgFEE8VwJLX github.com/tendermint/tm-db v0.6.2/go.mod h1:GYtQ67SUvATOcoY8/+x6ylk8Qo02BQyLrAs+yAcLvGI= github.com/tendermint/tm-db v0.6.3/go.mod h1:lfA1dL9/Y/Y8wwyPp2NMLyn5P5Ptr/gvDFNWtrCWSf8= github.com/tendermint/tm-db v0.6.4/go.mod h1:dptYhIpJ2M5kUuenLr+Yyf3zQOv1SgBZcl8/BmWlMBw= +github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= +github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg= github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU= github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg= @@ -1656,6 +1688,7 @@ github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vl github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -1711,6 +1744,9 @@ github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 h1:EKhdz github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1/go.mod h1:8UvriyWtv5Q5EOgjHaSseUEdkQfvwFv1I/In/O2M9gc= github.com/willf/bitset v1.1.3/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/wsddn/go-ecdh v0.0.0-20161211032359-48726bab9208/go.mod h1:IotVbo4F+mw0EzQ08zFqg7pK3FebNXpaMsRy2RT+Ees= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= @@ -1720,6 +1756,7 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1: github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/ybbus/jsonrpc v2.1.2+incompatible/go.mod h1:XJrh1eMSzdIYFbM08flv0wp5G35eRniyeGut1z+LSiE= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= @@ -1756,6 +1793,8 @@ go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mI go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= +go.mongodb.org/mongo-driver v1.11.0 h1:FZKhBSTydeuffHj9CBjXlR8vQLee1cQyTWYPA6/tqiE= +go.mongodb.org/mongo-driver v1.11.0/go.mod h1:s7p5vEtfbeR1gYi6pnj3c3/urpbLv2T5Sfd6Rp2HBB8= go.nhat.io/aferomock v0.4.0 h1:gs3nJzIqAezglUuaPfautAmZwulwRWLcfSSzdK4YCC0= go.nhat.io/grpcmock v0.25.0 h1:zk03vvA60w7UrnurZbqL4wxnjmJz1Kuyb7ig2MF+n4c= go.nhat.io/grpcmock v0.25.0/go.mod h1:5U694ASEFBkiZP7aPuz9kbbb/jphVlfpbOnocyht/rE= @@ -1796,6 +1835,7 @@ go.uber.org/fx v1.19.2 h1:SyFgYQFr1Wl0AYstE8vyYIzP4bFz2URrScjwC4cwUvY= go.uber.org/fx v1.19.2/go.mod h1:43G1VcqSzbIv77y00p1DRAsyZS8WdzuYdhZXmEUkMyQ= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= @@ -1803,6 +1843,8 @@ go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKY go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA= +go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= @@ -1811,6 +1853,7 @@ go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= @@ -1843,6 +1886,8 @@ golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1957,6 +2002,7 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= From 29de3125284c023aeb56d1f454f3637b47e177d7 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Tue, 25 Jun 2024 17:31:18 -0500 Subject: [PATCH 02/37] cmd(solana): sign ECDSA and build withdraw tx --- cmd/solana/main.go | 156 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 139 insertions(+), 17 deletions(-) diff --git a/cmd/solana/main.go b/cmd/solana/main.go index d1b3b0be28..a4515f2477 100644 --- a/cmd/solana/main.go +++ b/cmd/solana/main.go @@ -3,12 +3,15 @@ package main import ( "context" _ "embed" + "encoding/binary" + "encoding/hex" "encoding/json" "fmt" "log" "os" "github.com/davecgh/go-spew/spew" + "github.com/ethereum/go-ethereum/crypto" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" "github.com/near/borsh-go" @@ -121,25 +124,31 @@ func main() { spew.Dump(tx) } - { - //system.NewTransferInstruction() - //// build a deposit transaction - //solana.NewTransaction( - // solana.Instruction{ - // - // } - // ) - pk := os.Getenv("SOLANA_WALLET_PK") - if pk == "" { - log.Fatal("SOLANA_WALLET_PK must be set (base58 encoded private key)") - } + pk := os.Getenv("SOLANA_WALLET_PK") + if pk == "" { + log.Fatal("SOLANA_WALLET_PK must be set (base58 encoded private key)") + } - privkey, err := solana.PrivateKeyFromBase58(pk) - if err != nil { - log.Fatalf("Error getting private key: %v", err) - } + privkey, err := solana.PrivateKeyFromBase58(pk) + if err != nil { + log.Fatalf("Error getting private key: %v", err) + } + fmt.Println("account public key:", privkey.PublicKey()) - fmt.Println("account public key:", privkey.PublicKey()) + ethPk := os.Getenv("ETH_WALLET_PK") + if ethPk == "" { + log.Fatal("ETH_WALLET_PK must be set (hex encoded private key)") + } + privkeyBytes, err := hex.DecodeString(ethPk) + if err != nil { + log.Fatalf("Error decoding hex private key: %v", err) + } + ethPrivkey, err := crypto.ToECDSA(privkeyBytes) + if err != nil { + log.Fatalf("Error converting to ECDSA: %v", err) + } + + { bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) if err != nil { log.Fatalf("Error getting balance: %v", err) @@ -232,6 +241,119 @@ func main() { // panic(err) //} //spew.Dump(sig) + } + + { + fmt.Printf("Build and broadcast a withdraw tx\n") + type WithdrawInstructionParams struct { + Discriminator [8]byte + Amount uint64 + Signature [64]byte + RecoveryID uint8 + MessageHash [32]byte + Nonce uint64 + } + // fetch PDA account + programId := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") + seed := []byte("meta") + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programId) + if err != nil { + panic(err) + } + fmt.Printf("computed pda: %s, bump %d\n", pdaComputed, bump) + type PdaInfo struct { + Discriminator [8]byte + Nonce uint64 + TssAddress [20]byte + Authority [32]byte + } + pdaInfo, err := client.GetAccountInfo(context.TODO(), pdaComputed) + if err != nil { + panic(err) + } + var pda PdaInfo + borsh.Deserialize(&pda, pdaInfo.Bytes()) + + //spew.Dump(pda) + // building the transaction + recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) + if err != nil { + panic(err) + } + fmt.Println("recent blockhash:", recent.Value.Blockhash) + var inst solana.GenericInstruction + + pdaBalance, err := client.GetBalance(context.TODO(), pdaComputed, rpc.CommitmentFinalized) + if err != nil { + panic(err) + } + fmt.Printf("PDA balance in SOL %f\n", float64(pdaBalance.Value)/1e9) + var message []byte + + amount := uint64(2_337_000) + to := privkey.PublicKey() + bytes := make([]byte, 8) + nonce := pda.Nonce + binary.BigEndian.PutUint64(bytes, nonce) + message = append(message, bytes...) + binary.BigEndian.PutUint64(bytes, amount) + message = append(message, bytes...) + message = append(message, to.Bytes()...) + messageHash := crypto.Keccak256Hash(message) + // this sig will be 65 bytes; R || S || V, where V is 0 or 1 + signature, err := crypto.Sign(messageHash.Bytes(), ethPrivkey) + if err != nil { + panic(err) + } + var sig [64]byte + copy(sig[:], signature[:64]) + inst.DataBytes, err = borsh.Serialize(WithdrawInstructionParams{ + Discriminator: [8]byte{183, 18, 70, 156, 148, 109, 161, 34}, + Amount: amount, + Signature: sig, + RecoveryID: signature[64], + MessageHash: messageHash, + Nonce: nonce, + }) + + 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 + inst.AccountValues = accountSlice + tx, err := solana.NewTransaction( + []solana.Instruction{&inst}, + recent.Value.Blockhash, + solana.TransactionPayer(privkey.PublicKey()), + ) + if err != nil { + panic(err) + } + _, err = tx.Sign( + func(key solana.PublicKey) *solana.PrivateKey { + if privkey.PublicKey().Equals(key) { + return &privkey + } + return nil + }, + ) + if err != nil { + panic(fmt.Errorf("unable to sign transaction: %w", err)) + } + + spew.Dump(tx) + txsig, err := client.SendTransactionWithOpts( + context.TODO(), + tx, + rpc.TransactionOpts{}, + ) + //broadcast success! see + if err != nil { + panic(err) + } + spew.Dump(txsig) } From fd648e77f86c23fc7c64f0d5b9c069be11dccb42 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Fri, 28 Jun 2024 03:11:49 -0500 Subject: [PATCH 03/37] e2e: localnet solana WIP: initialize gateway program --- Dockerfile-localnet | 3 +- cmd/solana/main.go | 1 + cmd/zetae2e/config/clients.go | 15 +- cmd/zetae2e/config/config.go | 5 + cmd/zetae2e/config/contracts.go | 6 + cmd/zetae2e/local/accounts.go | 1 + cmd/zetae2e/local/local.go | 6 + cmd/zetae2e/local/solana.go | 76 ++++++ contrib/localnet/docker-compose.yml | 12 + contrib/localnet/solana/Dockerfile.solana | 134 +++++++++++ contrib/localnet/solana/gateway-keypair.json | 1 + contrib/localnet/solana/gateway.so | Bin 0 -> 277704 bytes contrib/localnet/solana/start-solana.sh | 18 ++ e2e/config/config.go | 12 +- e2e/e2etests/e2etests.go | 24 ++ e2e/e2etests/test_migrate_chain_support.go | 1 + e2e/e2etests/test_solana_deposit.go | 232 +++++++++++++++++++ e2e/runner/runner.go | 12 + e2e/runner/setup_zeta.go | 10 + e2e/runner/solana.go | 50 ++++ 20 files changed, 612 insertions(+), 7 deletions(-) create mode 100644 cmd/zetae2e/local/solana.go create mode 100644 contrib/localnet/solana/Dockerfile.solana create mode 100644 contrib/localnet/solana/gateway-keypair.json create mode 100755 contrib/localnet/solana/gateway.so create mode 100644 contrib/localnet/solana/start-solana.sh create mode 100644 e2e/e2etests/test_solana_deposit.go create mode 100644 e2e/runner/solana.go diff --git a/Dockerfile-localnet b/Dockerfile-localnet index bacf8c19a4..c64895c460 100644 --- a/Dockerfile-localnet +++ b/Dockerfile-localnet @@ -17,7 +17,8 @@ COPY go.mod . COPY go.sum . RUN go mod download COPY version.sh . -COPY --exclude=*.sh --exclude=*.md --exclude=*.yml . . +#COPY --exclude=*.sh --exclude=*.md --exclude=*.yml . . +COPY . . RUN --mount=type=cache,target="/root/.cache/go-build" make install RUN --mount=type=cache,target="/root/.cache/go-build" make install-zetae2e diff --git a/cmd/solana/main.go b/cmd/solana/main.go index a4515f2477..d89c4f1a17 100644 --- a/cmd/solana/main.go +++ b/cmd/solana/main.go @@ -149,6 +149,7 @@ func main() { } { + // build & bcast a Depsosit tx bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) if err != nil { log.Fatalf("Error getting balance: %v", err) diff --git a/cmd/zetae2e/config/clients.go b/cmd/zetae2e/config/clients.go index ae56eaa9dd..129ed90d98 100644 --- a/cmd/zetae2e/config/clients.go +++ b/cmd/zetae2e/config/clients.go @@ -10,6 +10,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" + "github.com/gagliardetto/solana-go/rpc" "google.golang.org/grpc" "github.com/zeta-chain/zetacore/e2e/config" @@ -22,6 +23,7 @@ import ( // getClientsFromConfig get clients from config func getClientsFromConfig(ctx context.Context, conf config.Config, evmPrivKey string) ( *rpcclient.Client, + *rpc.Client, *ethclient.Client, *bind.TransactOpts, crosschaintypes.QueryClient, @@ -34,25 +36,30 @@ func getClientsFromConfig(ctx context.Context, conf config.Config, evmPrivKey st *bind.TransactOpts, error, ) { + solanaClient := rpc.New(conf.RPCs.SolanaRPC) + if solanaClient == nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get solana client") + } btcRPCClient, err := getBtcClient(conf.RPCs.Bitcoin) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get btc client: %w", err) + return 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, evmPrivKey) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get evm client: %w", err) + return 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, 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, evmPrivKey) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get zevm client: %w", err) + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get zevm client: %w", err) } return btcRPCClient, + solanaClient, evmClient, evmAuth, cctxClient, diff --git a/cmd/zetae2e/config/config.go b/cmd/zetae2e/config/config.go index 7918a9a9ae..eadfcd9e12 100644 --- a/cmd/zetae2e/config/config.go +++ b/cmd/zetae2e/config/config.go @@ -23,6 +23,7 @@ func RunnerFromConfig( ) (*runner.E2ERunner, error) { // initialize clients btcRPCClient, + solanaClient, evmClient, evmAuth, cctxClient, @@ -56,6 +57,8 @@ func RunnerFromConfig( evmAuth, zevmAuth, btcRPCClient, + solanaClient, + logger, opts..., ) @@ -78,6 +81,8 @@ func RunnerFromConfig( // ExportContractsFromRunner export contracts from the runner to config using a source config func ExportContractsFromRunner(r *runner.E2ERunner, conf config.Config) config.Config { + conf.Contracts.Solana.GatewayProgramID = r.GatewayProgram.String() + // copy contracts from deployer runner conf.Contracts.EVM.ZetaEthAddress = r.ZetaEthAddr.Hex() conf.Contracts.EVM.ConnectorEthAddr = r.ConnectorEthAddr.Hex() diff --git a/cmd/zetae2e/config/contracts.go b/cmd/zetae2e/config/contracts.go index a47b39ef4e..8a953a068b 100644 --- a/cmd/zetae2e/config/contracts.go +++ b/cmd/zetae2e/config/contracts.go @@ -4,6 +4,7 @@ import ( "fmt" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/erc20custody.sol" zetaeth "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/zeta.eth.sol" zetaconnectoreth "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/zetaconnector.eth.sol" @@ -25,6 +26,11 @@ import ( func setContractsFromConfig(r *runner.E2ERunner, conf config.Config) error { var err error + // set Solana contracts + if c := conf.Contracts.Solana.GatewayProgramID; c != "" { + r.GatewayProgram = solana.MustPublicKeyFromBase58(c) + } + // set EVM contracts if c := conf.Contracts.EVM.ZetaEthAddress; c != "" { if !ethcommon.IsHexAddress(c) { diff --git a/cmd/zetae2e/local/accounts.go b/cmd/zetae2e/local/accounts.go index 16ab7d97a4..ca6c83c68c 100644 --- a/cmd/zetae2e/local/accounts.go +++ b/cmd/zetae2e/local/accounts.go @@ -41,4 +41,5 @@ var ( // FungibleAdminAddress is the address of the account for testing the fungible admin functions UserFungibleAdminAddress = ethcommon.HexToAddress("0x8305C114Ea73cAc4A88f39A173803F94741b9055") UserFungibleAdminPrivateKey = "d88d09a7d6849c15a36eb6931f9dd616091a63e9849a2cc86f309ba11fb8fec5" // #nosec G101 - used for testing + ) diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 364bdf80a4..94892cbe29 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -281,6 +281,11 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestMessagePassingZEVMtoEVMRevertFailName, e2etests.TestMessagePassingEVMtoZEVMRevertFailName, } + solanaTests := []string{ + e2etests.TestSolanaIntializeGatewayName, + e2etests.TestSolanaDepositName, + } + bitcoinTests := []string{ e2etests.TestBitcoinDepositName, e2etests.TestBitcoinDepositRefundName, @@ -322,6 +327,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(ethereumTestRoutine(conf, deployerRunner, verbose, testHeader, ethereumTests...)) } if testAdmin { diff --git a/cmd/zetae2e/local/solana.go b/cmd/zetae2e/local/solana.go new file mode 100644 index 0000000000..47f059f880 --- /dev/null +++ b/cmd/zetae2e/local/solana.go @@ -0,0 +1,76 @@ +package local + +import ( + "fmt" + "runtime" + "time" + + "github.com/fatih/color" + + "github.com/zeta-chain/zetacore/e2e/config" + "github.com/zeta-chain/zetacore/e2e/e2etests" + "github.com/zeta-chain/zetacore/e2e/runner" +) + +// bitcoinTestRoutine runs Bitcoin related e2e tests +func solanaTestRoutine( + conf config.Config, + deployerRunner *runner.E2ERunner, + verbose bool, + initBitcoinNetwork bool, + testHeader bool, + testNames ...string, +) func() error { + return func() (err error) { + // return an error on panic + // TODO: remove and instead return errors in the tests + // https://github.com/zeta-chain/node/issues/1500 + defer func() { + if r := recover(); r != nil { + // print stack trace + stack := make([]byte, 4096) + n := runtime.Stack(stack, false) + err = fmt.Errorf("solana panic: %v, stack trace %s", r, stack[:n]) + } + }() + + // initialize runner for bitcoin test + solanaRunner, err := initTestRunner( + "bitcoin", + conf, + deployerRunner, + UserBitcoinAddress, + UserBitcoinPrivateKey, + runner.NewLogger(verbose, color.FgCyan, "solana"), + ) + if err != nil { + return err + } + + solanaRunner.Logger.Print("🏃 starting Solana tests") + startTime := time.Now() + + // run bitcoin test + // Note: due to the extensive block generation in Bitcoin localnet, block header test is run first + // to make it faster to catch up with the latest block header + testsToRun, err := solanaRunner.GetE2ETestsToRunByName( + e2etests.AllE2ETests, + testNames..., + ) + if err != nil { + return fmt.Errorf("solana tests failed: %v", err) + } + + if err := solanaRunner.RunE2ETests(testsToRun); err != nil { + return fmt.Errorf("solana tests failed: %v", err) + } + + if err := solanaRunner.CheckBtcTSSBalance(); err != nil { + return err + } + + solanaRunner.Logger.Print("🍾 Solana tests completed in %s", time.Since(startTime).String()) + + return err + } +} diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index f176c30f00..16d93faa38 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -104,6 +104,18 @@ services: - ssh:/root/.ssh - preparams:/root/preparams + solana: + image: solana-local:latest + container_name: solana + hostname: solana + ports: + - "8899:8899" + networks: + mynetwork: + ipv4_address: 172.20.0.102 + entrypoint: [ "/usr/bin/start-solana.sh" ] + + eth: image: ethereum/client-go:v1.10.26 container_name: eth diff --git a/contrib/localnet/solana/Dockerfile.solana b/contrib/localnet/solana/Dockerfile.solana new file mode 100644 index 0000000000..a969706f11 --- /dev/null +++ b/contrib/localnet/solana/Dockerfile.solana @@ -0,0 +1,134 @@ +# Dockerfile +# solana-docker-mac-m1 +# +# Created by Raphael Tang on 12/6/2023. +# Licensed 2023 under MIT. All rights reserved. + +# ________ ________ ___ ________ ________ ________ +# |\ ____\|\ __ \|\ \ |\ __ \|\ ___ \|\ __ \ +# \ \ \___|\ \ \|\ \ \ \ \ \ \|\ \ \ \\ \ \ \ \|\ \ +# \ \_____ \ \ \\\ \ \ \ \ \ __ \ \ \\ \ \ \ __ \ +# \|____|\ \ \ \\\ \ \ \____\ \ \ \ \ \ \\ \ \ \ \ \ \ +# ____\_\ \ \_______\ \_______\ \__\ \__\ \__\\ \__\ \__\ \__\ +# |\_________\|_______|\|_______|\|__|\|__|\|__| \|__|\|__|\|__| +# \|_________| +# +# This Dockerfile contains a definition for a container which builds Solana from source +# in the build process. It should work on other operating systems too, but don't quote me on that. + +# Set directory used for all build generated files +ARG CUBICLE=/root +# Set your solana version here +ARG SOLANA_VERSION=1.18.15 +# Set folder name for intermediary files during build step +ARG BUILD_OUTPUT_DIR=${CUBICLE}/solana-output + +FROM --platform=linux/arm64 debian:stable-slim as base + +SHELL [ "/bin/bash", "-c" ] + +RUN apt update && \ + apt-get install -y \ + curl wget neovim fish \ + pkg-config bzip2 \ + && \ + rm -rf /var/lib/apt/lists/* + +# Container for building the solana binaries +FROM base as builder +ARG CUBICLE +ARG SOLANA_VERSION +ARG BUILD_OUTPUT_DIR + +RUN apt update && \ + apt-get install -y \ + build-essential \ + libssl-dev libudev-dev clang \ + gcc zlib1g-dev llvm cmake make \ + libprotobuf-dev protobuf-compiler \ + perl libfindbin-libs-perl \ + && \ + rm -rf /var/lib/apt/lists/* + +# Fetch solana source code +WORKDIR ${CUBICLE} +RUN if [[ "${SOLANA_VERSION}" == "latest" ]] ;\ + then \ + wget -O solana.tar.gz \ + https://github.com/solana-labs/solana/archive/refs/heads/master.tar.gz ;\ + else \ + wget -O solana.tar.gz \ + https://github.com/solana-labs/solana/archive/refs/tags/v${SOLANA_VERSION}.tar.gz ;\ + fi +RUN mkdir solana && \ + tar --extract --verbose --gzip --file solana.tar.gz --strip-components=1 --directory solana + +# Setup rust with compatible version +RUN source solana/ci/rust-version.sh && \ + curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain "$rust_stable" +ENV PATH=${CUBICLE}/.cargo/bin:$PATH + +# Build Solana binaries +WORKDIR ${CUBICLE}/solana +# FIXME: --validator-only is a temporary fix for +# [solana-labs/solana issue 31528](https://github.com/solana-labs/solana/issues/31528) +RUN ./scripts/cargo-install-all.sh "${BUILD_OUTPUT_DIR}" --validator-only +RUN cargo build --bin solana-test-validator --release +RUN cd "${BUILD_OUTPUT_DIR}/bin" && "${CUBICLE}/solana/fetch-spl.sh" +RUN cp -f scripts/run.sh "${BUILD_OUTPUT_DIR}/bin/run-cluster" +RUN cp -f fetch-spl.sh "${BUILD_OUTPUT_DIR}/bin/" +RUN cp -f target/release/solana-test-validator "${BUILD_OUTPUT_DIR}/bin/" + +# Final resulting multipurpose container +FROM base as final +ARG CUBICLE +ARG SOLANA_VERSION +ARG BUILD_OUTPUT_DIR + +COPY --from=builder ${BUILD_OUTPUT_DIR} /usr/ + +# RPC JSON +EXPOSE 8899/tcp +# RPC pubsub +EXPOSE 8900/tcp +# entrypoint +EXPOSE 8001/tcp +# (future) bank service +EXPOSE 8901/tcp +# bank service +EXPOSE 8902/tcp +# faucet +EXPOSE 9900/tcp +# tvu +EXPOSE 8000/udp +# gossip +EXPOSE 8001/udp +# tvu_forwards +EXPOSE 8002/udp +# tpu +EXPOSE 8003/udp +# tpu_forwards +EXPOSE 8004/udp +# retransmit +EXPOSE 8005/udp +# repair +EXPOSE 8006/udp +# serve_repair +EXPOSE 8007/udp +# broadcast +EXPOSE 8008/udp +# tpu_vote +EXPOSE 8009/udp + +RUN apt-get install -y bash + +WORKDIR /data +COPY ./start-solana.sh /usr/bin/start-solana.sh +RUN chmod +x /usr/bin/start-solana.sh +COPY ./gateway.so . +COPY ./gateway-keypair.json . + + + +ENTRYPOINT [ "bash" ] +CMD [ "/usr/bin/start-solana.sh" ] \ No newline at end of file diff --git a/contrib/localnet/solana/gateway-keypair.json b/contrib/localnet/solana/gateway-keypair.json new file mode 100644 index 0000000000..99c8b61dee --- /dev/null +++ b/contrib/localnet/solana/gateway-keypair.json @@ -0,0 +1 @@ +[148,138,110,3,169,253,42,101,79,110,149,110,112,214,41,163,75,28,36,29,241,151,41,200,135,185,252,180,158,191,166,156,119,192,217,18,69,149,119,145,212,43,144,149,176,111,89,140,102,63,193,127,241,148,51,161,170,62,19,196,239,253,6,192] \ No newline at end of file diff --git a/contrib/localnet/solana/gateway.so b/contrib/localnet/solana/gateway.so new file mode 100755 index 0000000000000000000000000000000000000000..dca31d76e37335c92ccfd422713a55582d8da6c6 GIT binary patch literal 277704 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 MqNIGw5l;Vq05AfNdH?_b literal 0 HcmV?d00001 diff --git a/contrib/localnet/solana/start-solana.sh b/contrib/localnet/solana/start-solana.sh new file mode 100644 index 0000000000..d87e9672ae --- /dev/null +++ b/contrib/localnet/solana/start-solana.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +echo "making an id" +solana-keygen new -o /root/.config/solana/id.json --no-bip39-passphrase + +solana config set --url localhost +echo "starting solana test validator..." +solana-test-validator & + +sleep 5 +# airdrop to e2e sol account +solana airdrop 100 +solana airdrop 100 37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ +solana program deploy gateway.so + + +# leave some time for debug if validator exits due to errors +sleep 1000 \ No newline at end of file diff --git a/e2e/config/config.go b/e2e/config/config.go index 450b02c5a3..24e3f31b41 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -30,6 +30,7 @@ type RPCs struct { Bitcoin BitcoinRPC `yaml:"bitcoin"` ZetaCoreGRPC string `yaml:"zetacore_grpc"` ZetaCoreRPC string `yaml:"zetacore_rpc"` + SolanaRPC string `yaml:"solana_rpc"` } // BitcoinRPC contains the configuration for the Bitcoin RPC endpoint @@ -44,8 +45,14 @@ type BitcoinRPC struct { // Contracts contains the addresses of predeployed contracts type Contracts struct { - EVM EVM `yaml:"evm"` - ZEVM ZEVM `yaml:"zevm"` + EVM EVM `yaml:"evm"` + ZEVM ZEVM `yaml:"zevm"` + Solana Solana `yaml:"solana"` +} + +// Solana contains the addresses of predeployed contracts on the Solana chain +type Solana struct { + GatewayProgramID string `yaml:"gateway_program_id"` } // EVM contains the addresses of predeployed contracts on the EVM chain @@ -88,6 +95,7 @@ func DefaultConfig() Config { }, ZetaCoreGRPC: "zetacore0:9090", ZetaCoreRPC: "http://zetacore0:26657", + SolanaRPC: "http://solana:8899", }, ZetaChainID: "athens_101-1", Contracts: Contracts{ diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 396b725617..b18826c00f 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -51,6 +51,12 @@ const ( TestERC20DepositRestrictedName = "erc20_deposit_restricted" // #nosec G101: Potential hardcoded credentials (gosec), not a credential TestERC20DepositAndCallRefundName = "erc20_deposit_and_call_refund" + /* + Solana tests + */ + TestSolanaIntializeGatewayName = "solana_initialize_gateway" + TestSolanaDepositName = "solana_deposit" + /* Bitcoin tests Test transfer of Bitcoin asset across chains @@ -323,6 +329,24 @@ var AllE2ETests = []runner.E2ETest{ []runner.ArgDefinition{}, TestERC20DepositAndCallRefund, ), + /* + Solana tests + */ + runner.NewE2ETest( + TestSolanaIntializeGatewayName, + "initialize Solana gateway", + []runner.ArgDefinition{}, + TestSolanaInitializeGateway, + ), + runner.NewE2ETest( + TestSolanaDepositName, + "deposit Sol into ZEVM", + []runner.ArgDefinition{ + {Description: "amount in SOL", DefaultValue: "0.1"}, + }, + TestSolanaDeposit, + ), + /* Bitcoin tests */ diff --git a/e2e/e2etests/test_migrate_chain_support.go b/e2e/e2etests/test_migrate_chain_support.go index f39bcbc2a3..deb132d519 100644 --- a/e2e/e2etests/test_migrate_chain_support.go +++ b/e2e/e2etests/test_migrate_chain_support.go @@ -249,6 +249,7 @@ func configureEVM2(r *runner.E2ERunner) (*runner.E2ERunner, error) { r.EVMAuth, r.ZEVMAuth, r.BtcRPCClient, + r.SolanaClient, runner.NewLogger(true, color.FgHiYellow, "admin-evm2"), runner.WithZetaTxServer(r.ZetaTxServer), ) diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go new file mode 100644 index 0000000000..1978ea6e61 --- /dev/null +++ b/e2e/e2etests/test_solana_deposit.go @@ -0,0 +1,232 @@ +package e2etests + +import ( + "context" + "fmt" + "time" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" + "github.com/zeta-chain/zetacore/e2e/runner" +) + +func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { + if len(args) != 0 { + panic("TestSolanaIntializeGateway requires exactly zero argument for the amount.") + } + + client := r.SolanaClient + // building the transaction + recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) + if err != nil { + panic(err) + } + r.Logger.Print("recent blockhash: %s", recent.Value.Blockhash) + + programId := solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") + seed := []byte("meta") + 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") + r.Logger.Print("pubkey: %s", privkey.PublicKey().String()) + bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) + if err != nil { + panic(err) + } + r.Logger.Print("account balance in SOL %f:", float64(bal.Value)/1e9) + + var inst solana.GenericInstruction + accountSlice := []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + accountSlice = append(accountSlice, solana.Meta(programId)) + inst.ProgID = programId + inst.AccountValues = accountSlice + + type InitializeParams struct { + Discriminator [8]byte + TssAddress [20]byte + } + 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, + }) + if err != nil { + panic(err) + } + + tx, err := solana.NewTransaction( + []solana.Instruction{&inst}, + recent.Value.Blockhash, + solana.TransactionPayer(privkey.PublicKey()), + ) + if err != nil { + panic(err) + } + _, err = tx.Sign( + func(key solana.PublicKey) *solana.PrivateKey { + if privkey.PublicKey().Equals(key) { + return &privkey + } + return nil + }, + ) + if err != nil { + panic(fmt.Errorf("unable to sign transaction: %w", err)) + } + sig, err := client.SendTransactionWithOpts( + context.TODO(), + tx, + rpc.TransactionOpts{}, + ) + if err != nil { + panic(err) + } + r.Logger.Print("broadcast success! tx sig %s; waiting for confirmation...", sig) + time.Sleep(16 * time.Second) + type PdaInfo struct { + Discriminator [8]byte + Nonce uint64 + TssAddress [20]byte + Authority [32]byte + } + pdaInfo, err := client.GetAccountInfo(context.TODO(), pdaComputed) + if err != nil { + r.Logger.Print("error getting PDA info: %v", err) + panic(err) + } + var pda PdaInfo + borsh.Deserialize(&pda, pdaInfo.Bytes()) + + r.Logger.Print("PDA info Tss: %v", pda.TssAddress) + +} + +func TestSolanaDeposit(r *runner.E2ERunner, args []string) { + /* + if len(args) != 1 { + panic("TestSolanaDeposit requires exactly one argument for the amount.") + } + + depositAmount, err := strconv.ParseFloat(args[0], 64) + if err != nil { + panic("Invalid deposit amount specified for TestBitcoinDeposit.") + } + + client := r.SolanaClient + + // build & bcast a Depsosit tx + bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) + if err != nil { + log.Fatalf("Error getting balance: %v", err) + } + fmt.Println("account balance in SOL ", float64(bal.Value)/1e9) + + // building the transaction + recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) + if err != nil { + panic(err) + } + fmt.Println("recent blockhash:", recent.Value.Blockhash) + + programId := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") + seed := []byte("meta") + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programId) + if err != nil { + panic(err) + } + fmt.Printf("computed pda: %s, bump %d\n", pdaComputed, bump) + + //pdaAccount := solana.MustPublicKeyFromBase58("4hA43LCh2Utef8EwCyWwYmWBoSeNq6RS2HdoLkWGm5z5") + var inst solana.GenericInstruction + accountSlice := []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + accountSlice = append(accountSlice, solana.Meta(programId)) + inst.ProgID = programId + inst.AccountValues = accountSlice + + type DepositInstructionParams struct { + Discriminator [8]byte + Amount uint64 + Memo []byte + } + + inst.DataBytes, err = borsh.Serialize(DepositInstructionParams{ + Discriminator: [8]byte{0xf2, 0x23, 0xc6, 0x89, 0x52, 0xe1, 0xf2, 0xb6}, + Amount: 1338, + Memo: []byte("hello this is a good memo for you to enjoy"), + }) + //inst.DataBytes, err = hex.DecodeString("f223c68952e1f2b6390500000000000014000000dead000000000000000042069420694206942069") + if err != nil { + panic(err) + } + + tx, err := solana.NewTransaction( + []solana.Instruction{&inst}, + recent.Value.Blockhash, + solana.TransactionPayer(privkey.PublicKey()), + ) + if err != nil { + panic(err) + } + _, err = tx.Sign( + func(key solana.PublicKey) *solana.PrivateKey { + if privkey.PublicKey().Equals(key) { + return &privkey + } + return nil + }, + ) + if err != nil { + panic(fmt.Errorf("unable to sign transaction: %w", err)) + } + + spew.Dump(tx) + //wsClient, err := ws.Connect(context.Background(), rpc.DevNet_WS) + //if err != nil { + // panic(err) + //} + //sig, err := confirm.SendAndConfirmTransaction( + // context.TODO(), + // client, + // wsClient, + // tx, + //) + // tx: 33cVywTwufSy5NsNSnJS87wmkPwVAr9iiJqxAhhny9pazxWpiH6L24c6ruVnSjctcGasyt2ngnrtx3TqK6KU6x6j + + //sig, err := client.SendTransactionWithOpts( + // context.TODO(), + // tx, + // rpc.TransactionOpts{}, + //) + // broadcast success! see + // https://solana.fm/tx/43hXUywVouKeG5V98mjPysPWG9eKyKo6XDVHuoQs5YP1gJfa5z2UtU6hjJGgscrWzmYbhbqNW2hykvV6HYfBXATD + + //if err != nil { + // panic(err) + //} + //spew.Dump(sig) + + // wait for the cctx to be mined + cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, txHash.String(), r.CctxClient, r.Logger, r.CctxTimeout) + r.Logger.CCTX(*cctx, "deposit") + if cctx.CctxStatus.Status != crosschaintypes.CctxStatus_OutboundMined { + panic(fmt.Sprintf( + "expected mined status; got %s, message: %s", + cctx.CctxStatus.Status.String(), + cctx.CctxStatus.StatusMessage), + ) + } + */ + +} diff --git a/e2e/runner/runner.go b/e2e/runner/runner.go index 2f2f0e49cc..698ca07ec6 100644 --- a/e2e/runner/runner.go +++ b/e2e/runner/runner.go @@ -14,6 +14,8 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/erc20custody.sol" zetaeth "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/zeta.eth.sol" zetaconnectoreth "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/zetaconnector.eth.sol" @@ -57,6 +59,7 @@ type E2ERunner struct { ZEVMClient *ethclient.Client EVMClient *ethclient.Client BtcRPCClient *rpcclient.Client + SolanaClient *rpc.Client // grpc clients CctxClient crosschaintypes.QueryClient @@ -75,6 +78,9 @@ type E2ERunner struct { EVMAuth *bind.TransactOpts ZEVMAuth *bind.TransactOpts + // programs on Solana + GatewayProgram solana.PublicKey + // contracts evm ZetaEthAddr ethcommon.Address ZetaEth *zetaeth.ZetaEth @@ -139,6 +145,7 @@ func NewE2ERunner( evmAuth *bind.TransactOpts, zevmAuth *bind.TransactOpts, btcRPCClient *rpcclient.Client, + solanaClient *rpc.Client, logger *Logger, opts ...E2ERunnerOption, ) *E2ERunner { @@ -162,6 +169,7 @@ func NewE2ERunner( EVMAuth: evmAuth, ZEVMAuth: zevmAuth, BtcRPCClient: btcRPCClient, + SolanaClient: solanaClient, Logger: logger, } @@ -195,6 +203,8 @@ func (runner *E2ERunner) CopyAddressesFrom(other *E2ERunner) (err error) { runner.SystemContractAddr = other.SystemContractAddr runner.ZevmTestDAppAddr = other.ZevmTestDAppAddr + runner.GatewayProgram = other.GatewayProgram + // create instances of contracts runner.ZetaEth, err = zetaeth.NewZetaEth(runner.ZetaEthAddr, runner.EVMClient) if err != nil { @@ -270,6 +280,8 @@ func (runner *E2ERunner) Unlock() { // the printed contracts are grouped in a zevm and evm section // there is a padding used to print the addresses at the same position func (runner *E2ERunner) PrintContractAddresses() { + runner.Logger.Print(" --- 📜Solana addresses ---") + runner.Logger.Print("GatewayProgram: %s", runner.GatewayProgram.String()) // zevm contracts runner.Logger.Print(" --- 📜zEVM contracts ---") runner.Logger.Print("SystemContract: %s", runner.SystemContractAddr.Hex()) diff --git a/e2e/runner/setup_zeta.go b/e2e/runner/setup_zeta.go index ac24c02de2..ff9a02246c 100644 --- a/e2e/runner/setup_zeta.go +++ b/e2e/runner/setup_zeta.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" "github.com/zeta-chain/protocol-contracts/pkg/contracts/zevm/systemcontract.sol" "github.com/zeta-chain/protocol-contracts/pkg/contracts/zevm/wzeta.sol" connectorzevm "github.com/zeta-chain/protocol-contracts/pkg/contracts/zevm/zetaconnectorzevm.sol" @@ -66,6 +67,15 @@ func (runner *E2ERunner) SetTSSAddresses() error { return nil } +// SetSolanaContracts set Solana contracts +func (runner *E2ERunner) SetSolanaContracts() { + runner.Logger.Print("⚙️ setting up Solana contracts") + + // set Solana contracts + // TODO: remove this hardcoded stuff for localnet + runner.GatewayProgram = solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") +} + // SetZEVMContracts set contracts for the ZEVM func (runner *E2ERunner) SetZEVMContracts() { runner.Logger.Print("⚙️ deploying system contracts and ZRC20s on ZEVM") diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go new file mode 100644 index 0000000000..27b2d4eddc --- /dev/null +++ b/e2e/runner/solana.go @@ -0,0 +1,50 @@ +package runner + +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") + + // list deployer utxos + utxos, err := runner.ListDeployerUTXOs() + if err != nil { + panic(err) + } + + spendableAmount := 0.0 + spendableUTXOs := 0 + for _, utxo := range utxos { + if utxo.Spendable { + spendableAmount += utxo.Amount + spendableUTXOs++ + } + } + + if spendableAmount < amount { + panic(fmt.Errorf( + "not enough spendable BTC to run the test; have %f, require %f", + spendableAmount, + amount, + )) + } + + 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...") + + amount = amount + zetabitcoin.DefaultDepositorFee + txHash, err = runner.SendToTSSFromDeployerToDeposit(amount, utxos) + if err != nil { + panic(err) + } + runner.Logger.Info("send BTC to TSS txHash: %s", txHash.String()) + + return txHash +} From bc1fc2c2031f96ed8fc30046f3634985903c1fc3 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Sat, 29 Jun 2024 01:04:19 -0500 Subject: [PATCH 04/37] e2e(solana): start deposit test --- e2e/e2etests/test_solana_deposit.go | 229 ++++++++++++++-------------- 1 file changed, 111 insertions(+), 118 deletions(-) diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go index 1978ea6e61..c48a4361c0 100644 --- a/e2e/e2etests/test_solana_deposit.go +++ b/e2e/e2etests/test_solana_deposit.go @@ -33,7 +33,7 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { r.Logger.Print("computed pda: %s, bump %d\n", pdaComputed, bump) privkey := solana.MustPrivateKeyFromBase58("4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C") - r.Logger.Print("pubkey: %s", privkey.PublicKey().String()) + r.Logger.Print("user pubkey: %s", privkey.PublicKey().String()) bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) if err != nil { panic(err) @@ -111,122 +111,115 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { } func TestSolanaDeposit(r *runner.E2ERunner, args []string) { - /* - if len(args) != 1 { - panic("TestSolanaDeposit requires exactly one argument for the amount.") - } - - depositAmount, err := strconv.ParseFloat(args[0], 64) - if err != nil { - panic("Invalid deposit amount specified for TestBitcoinDeposit.") - } - - client := r.SolanaClient - - // build & bcast a Depsosit tx - bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) - if err != nil { - log.Fatalf("Error getting balance: %v", err) - } - fmt.Println("account balance in SOL ", float64(bal.Value)/1e9) - - // building the transaction - recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) - if err != nil { - panic(err) - } - fmt.Println("recent blockhash:", recent.Value.Blockhash) - - programId := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") - seed := []byte("meta") - pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programId) - if err != nil { - panic(err) - } - fmt.Printf("computed pda: %s, bump %d\n", pdaComputed, bump) - - //pdaAccount := solana.MustPublicKeyFromBase58("4hA43LCh2Utef8EwCyWwYmWBoSeNq6RS2HdoLkWGm5z5") - var inst solana.GenericInstruction - accountSlice := []*solana.AccountMeta{} - accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) - accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) - accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) - accountSlice = append(accountSlice, solana.Meta(programId)) - inst.ProgID = programId - inst.AccountValues = accountSlice - - type DepositInstructionParams struct { - Discriminator [8]byte - Amount uint64 - Memo []byte - } - - inst.DataBytes, err = borsh.Serialize(DepositInstructionParams{ - Discriminator: [8]byte{0xf2, 0x23, 0xc6, 0x89, 0x52, 0xe1, 0xf2, 0xb6}, - Amount: 1338, - Memo: []byte("hello this is a good memo for you to enjoy"), - }) - //inst.DataBytes, err = hex.DecodeString("f223c68952e1f2b6390500000000000014000000dead000000000000000042069420694206942069") - if err != nil { - panic(err) - } - - tx, err := solana.NewTransaction( - []solana.Instruction{&inst}, - recent.Value.Blockhash, - solana.TransactionPayer(privkey.PublicKey()), - ) - if err != nil { - panic(err) - } - _, err = tx.Sign( - func(key solana.PublicKey) *solana.PrivateKey { - if privkey.PublicKey().Equals(key) { - return &privkey - } - return nil - }, - ) - if err != nil { - panic(fmt.Errorf("unable to sign transaction: %w", err)) - } - - spew.Dump(tx) - //wsClient, err := ws.Connect(context.Background(), rpc.DevNet_WS) - //if err != nil { - // panic(err) - //} - //sig, err := confirm.SendAndConfirmTransaction( - // context.TODO(), - // client, - // wsClient, - // tx, - //) - // tx: 33cVywTwufSy5NsNSnJS87wmkPwVAr9iiJqxAhhny9pazxWpiH6L24c6ruVnSjctcGasyt2ngnrtx3TqK6KU6x6j - - //sig, err := client.SendTransactionWithOpts( - // context.TODO(), - // tx, - // rpc.TransactionOpts{}, - //) - // broadcast success! see - // https://solana.fm/tx/43hXUywVouKeG5V98mjPysPWG9eKyKo6XDVHuoQs5YP1gJfa5z2UtU6hjJGgscrWzmYbhbqNW2hykvV6HYfBXATD - - //if err != nil { - // panic(err) - //} - //spew.Dump(sig) - - // wait for the cctx to be mined - cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, txHash.String(), r.CctxClient, r.Logger, r.CctxTimeout) - r.Logger.CCTX(*cctx, "deposit") - if cctx.CctxStatus.Status != crosschaintypes.CctxStatus_OutboundMined { - panic(fmt.Sprintf( - "expected mined status; got %s, message: %s", - cctx.CctxStatus.Status.String(), - cctx.CctxStatus.StatusMessage), - ) - } - */ + client := r.SolanaClient + + privkey := solana.MustPrivateKeyFromBase58("4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C") + + // build & bcast a Depsosit tx + bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) + if err != nil { + r.Logger.Error("Error getting balance: %v", err) + panic(fmt.Sprintf("Error getting balance: %v", err)) + } + r.Logger.Print("account balance in SOL %f", float64(bal.Value)/1e9) + + // building the transaction + recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) + if err != nil { + r.Logger.Error("Error getting recent blockhash: %v", err) + panic(err) + } + r.Logger.Print("recent blockhash:", recent.Value.Blockhash) + + programId := solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") + seed := []byte("meta") + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programId) + if err != nil { + r.Logger.Error("Error finding program address: %v", err) + panic(err) + } + r.Logger.Print("computed pda: %s, bump %d\n", pdaComputed, bump) + + //pdaAccount := solana.MustPublicKeyFromBase58("4hA43LCh2Utef8EwCyWwYmWBoSeNq6RS2HdoLkWGm5z5") + var inst solana.GenericInstruction + accountSlice := []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + accountSlice = append(accountSlice, solana.Meta(programId)) + inst.ProgID = programId + inst.AccountValues = accountSlice + + type DepositInstructionParams struct { + Discriminator [8]byte + Amount uint64 + Memo []byte + } + + inst.DataBytes, err = borsh.Serialize(DepositInstructionParams{ + Discriminator: [8]byte{0xf2, 0x23, 0xc6, 0x89, 0x52, 0xe1, 0xf2, 0xb6}, + Amount: 1338, + Memo: []byte("hello this is a good memo for you to enjoy"), + }) + if err != nil { + r.Logger.Error("Error serializing deposit instruction: %v", err) + panic(err) + } + + tx, err := solana.NewTransaction( + []solana.Instruction{&inst}, + recent.Value.Blockhash, + solana.TransactionPayer(privkey.PublicKey()), + ) + if err != nil { + r.Logger.Error("Error creating transaction: %v", err) + panic(err) + } + _, err = tx.Sign( + func(key solana.PublicKey) *solana.PrivateKey { + if privkey.PublicKey().Equals(key) { + return &privkey + } + return nil + }, + ) + if err != nil { + r.Logger.Error("Error signing transaction: %v", err) + panic(fmt.Errorf("unable to sign transaction: %w", err)) + } + + //spew.Dump(tx) + + sig, err := client.SendTransactionWithOpts( + context.TODO(), + tx, + rpc.TransactionOpts{}, + ) + if err != nil { + r.Logger.Error("Error sending transaction: %v", err) + panic(err) + } + r.Logger.Print("broadcast success! tx sig %s; waiting for confirmation...", sig) + time.Sleep(16 * time.Second) + + //spew.Dump(sig) + out, err := client.GetTransaction(context.TODO(), sig, &rpc.GetTransactionOpts{}) + if err != nil { + r.Logger.Error("Error getting transaction: %v", err) + panic(err) + } + r.Logger.Print("transaction status: %v, %v", out.Meta.Err, out.Meta.Status) + r.Logger.Print("transaction logs: %v", out.Meta.LogMessages) + + // wait for the cctx to be mined + //cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, txHash.String(), r.CctxClient, r.Logger, r.CctxTimeout) + //r.Logger.CCTX(*cctx, "deposit") + //if cctx.CctxStatus.Status != crosschaintypes.CctxStatus_OutboundMined { + // panic(fmt.Sprintf( + // "expected mined status; got %s, message: %s", + // cctx.CctxStatus.Status.String(), + // cctx.CctxStatus.StatusMessage), + // ) + //} } From c7a3b2cd84f92cb574ddc9516e07c9d8fdc01d33 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Tue, 2 Jul 2024 16:14:59 -0500 Subject: [PATCH 05/37] update solana program with chain id commit in TSS signature --- contrib/localnet/solana/gateway.so | Bin 277704 -> 278648 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/contrib/localnet/solana/gateway.so b/contrib/localnet/solana/gateway.so index dca31d76e37335c92ccfd422713a55582d8da6c6..185b938c3ca936bfc13c831b38af4b826e8bef8a 100755 GIT binary patch delta 53822 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 delta 53826 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
  • dVW{?}*I}we(y(1vfnMsC# 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�R|z+y;(-d%#1<>f(l7eQwis*IZTJq%`Yq$TWSmwQ2vNx&XKJ zrVJ|ryQ*}HeRQp^s${AGsBPd7n0BWwHUjq;I02riT`E?xeh;~+J)MiE)((^LRhf+P z58Fl6)-OGJq3@`@puLIyJ5hT&kdE6CGDTI^##5)sl>9%)x&yVX-66Ppzuq}wfAJu# z^SI|yVMmq9WE$$UHZ8TSO<(P36RQi_Ox3P7`)W@cTE*!YnVKqV(^RLm>8NdOsGK&j zdQlr{flSG{D)XwS@@=Fx)s{Aq+R=uJXhTJ`p`>mnLrL9kpP?_Hd6B4Gcw5%v0@u{q zacV9AcSlvpO{r~b6RRB^G=-pd-#8t!wI~6RXRQZH`sx$)k zL~Wg*9{m*DGvEa`PV`PuQhWTRN>!U7_4c18|Sjb=nd-1f`Rt zTJOr1>CWd#+Ri$-8)}Q(YoU5pt7A|TwM*`*HHEr>U0_d@Ds%vST91<64MBz89-)32 z=$A3Qtc}za9g(Q(+ALJ*4r<+dPd3v~PuyW2J=Ott4|w!%b?8&}{at@Ez2K&ctm%rH zz>X_*7dW{~>WfcHF5E3S{*2(RHLDAUHMn1L4D9++uK~}tq~7@o@B?|K-cRGfyo3Jt z0CvsOrt1`@_fWqjmHv$!>)zKm?;!S{%LUM%P2li?)aSs9 z=cVrbQgSWFY40O=K?aP?!;GBq;8#+g0uRiCh#Vh`Wc)s`XPyJ3_-?vOlgyEF=Ve(z z{}st&;MQNIUi`b{2)MUm+W-2P_RPakyanwKWCb1Iz&r}X@h$T>4s*{u?85Anm$N;Y zU1oj!bpqp)GGJzAmve!{Oe1Hmm}%q89&q~^Szia(F_WyjT)}uC3rv6$Gvk)y8)gD6 z^WYs>J_dHDQm-w!yLwynAYeX|1=@R(TV}@XDgj{EOu%J526q3e>!bb80YU1>c=|ut z-~w1LH>-cRd>hy|^FmqQ2X20Z%Io9L0h5o)3g^J%qSXD8Y7F7tcv-ClbAFZ@#mc>j`Id0%o3I5s1Ms|^6204EuLKE3)`W(1KdC;}J$BNK3eL$!Mk z{ofHSE6YC5fL$}t$j!8X!-M)#oP8WcH%Lx^y>+2?t*LZab2F^Q4LUcA0@l!xT-%Ub zG=oxHzF=0~Vs`G#$J4o#H1ZBwmFEkRLo-yt6;#Yn1@qJlO)!tl$OCg^MjDs{Gt!W9 zdM-aC8!T)~4uE|#62KMukIMKM*fRIwIo`|hvM-x+=&C|r-wSui1S*#g!huAbA8_Uv<`L z|LY1WW)%*-1)2xI1+%mT>%Lj4g4w+&8*E$xHY-VReE7D}_3=-)z${R}0W~Y@NTmth zC^<6E#&diE+%%7#vpzOYf-@IyUDlWOWzz<7o#~W6DI1J$&OT1G?tEOb4_tSoUf7iE z+>-LLFRLrG4$FY>h-CUkM;)~~#?GyhYty9+``^#>8%Xc%WV+_5nU<*Q|1kZ|Ud!V3U7JK-g!MA2I)Ik;v&OlLsP+|d zS6!M#X%)<@L9N|HzqA0+Up{BQa$xc!^|O2JJJ%QImI*A*sjK(ecdzqU%=D5vbRY51 z+|C%jr@o|_mzHb(QJp6)4ga((&-Svq{^x@qQ~$WnKD=73j#sTq)JEOD=f?VH(?e&` Ke0vjm)BgcKU}1;= From 94f3bbeff53c3409f8fce54cb89f2f9d799a260c Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Tue, 2 Jul 2024 16:25:03 -0500 Subject: [PATCH 06/37] fix the initialize of solana program after update the program --- e2e/e2etests/test_solana_deposit.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go index c48a4361c0..b022a90f1c 100644 --- a/e2e/e2etests/test_solana_deposit.go +++ b/e2e/e2etests/test_solana_deposit.go @@ -52,12 +52,14 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { type InitializeParams struct { Discriminator [8]byte TssAddress [20]byte + 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: 111111, }) if err != nil { panic(err) From aee1bd49b2fa0a5069c7303abb975c7446b3fdb7 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Wed, 3 Jul 2024 00:28:50 -0500 Subject: [PATCH 07/37] zetaclient(solana): observe and filter and parse deposit instruction --- cmd/zetaclientd/utils.go | 12 + zetaclient/chains/solana/constants.go | 7 + zetaclient/chains/solana/observer/observer.go | 233 ++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 zetaclient/chains/solana/constants.go create mode 100644 zetaclient/chains/solana/observer/observer.go diff --git a/cmd/zetaclientd/utils.go b/cmd/zetaclientd/utils.go index 64db1c3efa..ccdef48463 100644 --- a/cmd/zetaclientd/utils.go +++ b/cmd/zetaclientd/utils.go @@ -11,6 +11,8 @@ import ( evmobserver "github.com/zeta-chain/zetacore/zetaclient/chains/evm/observer" evmsigner "github.com/zeta-chain/zetacore/zetaclient/chains/evm/signer" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + "github.com/zeta-chain/zetacore/zetaclient/chains/solana" + solanaobserver "github.com/zeta-chain/zetacore/zetaclient/chains/solana/observer" "github.com/zeta-chain/zetacore/zetaclient/config" "github.com/zeta-chain/zetacore/zetaclient/context" "github.com/zeta-chain/zetacore/zetaclient/keys" @@ -146,5 +148,15 @@ func CreateChainObserverMap( } } + // TODO: config this + programId := "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" + co, err := solanaobserver.NewObserver(appContext, zetacoreClient, tss, programId, dbpath, ts) + if err != nil { + logger.Std.Error().Err(err).Msg("NewObserver error for solana chain") + } else { + // TODO: config this + observerMap[solana.LocalnetChainID] = co + } + return observerMap, nil } diff --git a/zetaclient/chains/solana/constants.go b/zetaclient/chains/solana/constants.go new file mode 100644 index 0000000000..ddd188ff90 --- /dev/null +++ b/zetaclient/chains/solana/constants.go @@ -0,0 +1,7 @@ +package solana + +const ( + LocalnetChainID = 28899 + TestnetChainID = 18899 + MainnetChainID = 8899 +) diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go new file mode 100644 index 0000000000..5e99d3e776 --- /dev/null +++ b/zetaclient/chains/solana/observer/observer.go @@ -0,0 +1,233 @@ +package observer + +import ( + "bytes" + "context" + "fmt" + "sync" + + "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/x/crosschain/types" + observertypes "github.com/zeta-chain/zetacore/x/observer/types" + "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + clientcontext "github.com/zeta-chain/zetacore/zetaclient/context" + "github.com/zeta-chain/zetacore/zetaclient/metrics" + clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" +) + +type Observer struct { + Tss interfaces.TSSSigner + zetacoreClient interfaces.ZetacoreClient + Mu *sync.Mutex + + chain chains.Chain + solanaClient *rpc.Client + + stop chan struct{} + logger zerolog.Logger + coreContext *clientcontext.ZetacoreContext + chainParams observertypes.ChainParams + programId solana.PublicKey + ts *metrics.TelemetryServer +} + +var _ interfaces.ChainObserver = &Observer{} + +// NewObserver returns a new EVM chain observer +// TODO: read config for testnet and mainnet +func NewObserver( + appContext *clientcontext.AppContext, + zetacoreClient interfaces.ZetacoreClient, + tss interfaces.TSSSigner, + programIdStr string, + dbpath string, + ts *metrics.TelemetryServer, +) (*Observer, error) { + ob := Observer{ + ts: ts, + } + + logger := log.With().Str("chain", "solana").Logger() + ob.logger = logger + + ob.coreContext = appContext.ZetacoreContext() + chainParams := observertypes.ChainParams{ + IsSupported: true, + } + ob.chainParams = chainParams + ob.stop = make(chan struct{}) + ob.Mu = &sync.Mutex{} + ob.zetacoreClient = zetacoreClient + ob.Tss = tss + ob.programId = solana.MustPublicKeyFromBase58(programIdStr) + + 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") + } + + 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)) + } + 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, + } +} + +func (o *Observer) GetTxID(nonce uint64) string { + //TODO implement me + panic("implement me") +} + +func (o *Observer) WatchInboundTracker() { + //TODO implement me + panic("implement me") +} + +func (o *Observer) Start() { + o.logger.Info().Msgf("observer starting...") + go o.WatchInbound() +} + +func (o *Observer) Stop() { + o.logger.Info().Msgf("observer stopping...") +} + +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 + } + } +} + +func (o *Observer) ObserveInbound() error { + limit := 100 + + out, err := o.solanaClient.GetSignaturesForAddressWithOpts( + context.TODO(), + o.programId, + &rpc.GetSignaturesForAddressOpts{ + Limit: &limit, + //Before: solana.MustSignatureFromBase58("5pLBywq74Nc6jYrWUqn9KjnYXHbQEY2UPkhWefZF5u4NYaUvEwz1Cirqaym9wDeHNAjiQwuLBfrdhXo8uFQA45jL"), + //Until: solana.MustSignatureFromBase58("2coX9CckSmJWeHVqJNANeD7m4J7pctpSomxMon3h36droxCVB3JDbLyWQKMjnf85ntuFGxMLySykEMaRd5MDw35e"), + 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-- { + 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 + } + type DepositInstructionParams struct { + Discriminator [8]byte + Amount uint64 + Memo []byte + } + transaction, _ := tx.Transaction.GetTransaction() + instruction := transaction.Message.Instructions[0] // TODO: 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) + //var accounts []solana.PublicKey + //for _, accIndex := range instruction.Accounts { + // accKey := transaction.Message.AccountKeys[accIndex] + // accounts = append(accounts, accKey) + //} + + } + return nil +} From 713e2c45411c759df018e243df8937308112d2d2 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Wed, 3 Jul 2024 00:55:50 -0500 Subject: [PATCH 08/37] zetaclient(solana): remember last observed tx to save RPC calls --- zetaclient/chains/solana/observer/observer.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index 5e99d3e776..3359ca9f87 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -35,6 +35,8 @@ type Observer struct { chainParams observertypes.ChainParams programId solana.PublicKey ts *metrics.TelemetryServer + + lastTxSig solana.Signature } var _ interfaces.ChainObserver = &Observer{} @@ -170,7 +172,7 @@ func (o *Observer) WatchInbound() { } func (o *Observer) ObserveInbound() error { - limit := 100 + limit := 1000 out, err := o.solanaClient.GetSignaturesForAddressWithOpts( context.TODO(), @@ -178,7 +180,7 @@ func (o *Observer) ObserveInbound() error { &rpc.GetSignaturesForAddressOpts{ Limit: &limit, //Before: solana.MustSignatureFromBase58("5pLBywq74Nc6jYrWUqn9KjnYXHbQEY2UPkhWefZF5u4NYaUvEwz1Cirqaym9wDeHNAjiQwuLBfrdhXo8uFQA45jL"), - //Until: solana.MustSignatureFromBase58("2coX9CckSmJWeHVqJNANeD7m4J7pctpSomxMon3h36droxCVB3JDbLyWQKMjnf85ntuFGxMLySykEMaRd5MDw35e"), + Until: o.lastTxSig, Commitment: rpc.CommitmentFinalized, }, ) @@ -188,7 +190,7 @@ func (o *Observer) ObserveInbound() error { } o.logger.Info().Msgf("GetSignaturesForAddressWithOpts length %d", len(out)) - for i := len(out) - 1; i >= 0; i-- { + 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 @@ -199,6 +201,7 @@ func (o *Observer) ObserveInbound() error { 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 From 3657a3baab083b65c5da2a5b889e98f9a8124da4 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Wed, 3 Jul 2024 01:28:44 -0500 Subject: [PATCH 09/37] zetaclient(solana): report to zetacored about deposit --- zetaclient/chains/solana/observer/observer.go | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index 3359ca9f87..dd6e1e61c5 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -3,9 +3,11 @@ 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" @@ -13,12 +15,15 @@ import ( "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/interfaces" + solanaclient "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" ) type Observer struct { @@ -225,11 +230,38 @@ func (o *Observer) ObserveInbound() error { } o.logger.Info().Msgf(" Amount Parameter: %d", inst.Amount) o.logger.Info().Msgf(" Memo (%d): %x", len(inst.Memo), inst.Memo) - //var accounts []solana.PublicKey - //for _, accIndex := range instruction.Accounts { - // accKey := transaction.Message.AccountKeys[accIndex] - // accounts = append(accounts, accKey) - //} + 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? + solanaclient.LocalnetChainID, + 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 From abb096a6bc56a4200e0817c51708a887a8b624a5 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Wed, 3 Jul 2024 11:30:03 -0500 Subject: [PATCH 10/37] localnet: deploy sol zrc20 --- e2e/txserver/zeta_tx_server.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index fb2c403ada..cdf4c92594 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -36,6 +36,7 @@ import ( "github.com/evmos/ethermint/crypto/hd" etherminttypes "github.com/evmos/ethermint/types" evmtypes "github.com/evmos/ethermint/x/evm/types" + solanaclient "github.com/zeta-chain/zetacore/zetaclient/chains/solana" "github.com/zeta-chain/zetacore/app" "github.com/zeta-chain/zetacore/cmd/zetacored/config" @@ -371,6 +372,21 @@ func (zts ZetaTxServer) DeploySystemContractsAndZRC20( return "", "", "", "", "", fmt.Errorf("failed to deploy btc zrc20: %s", err.Error()) } + // deploy sol zrc20 + _, err = zts.BroadcastTx(account, fungibletypes.NewMsgDeployFungibleCoinZRC20( + addr.String(), + "", + solanaclient.LocalnetChainID, + 9, + "Solana", + "SOL", + coin.CoinType_Gas, + 100000, + )) + if err != nil { + return "", "", "", "", "", fmt.Errorf("failed to deploy btc zrc20: %s", err.Error()) + } + // deploy erc20 zrc20 res, err = zts.BroadcastTx(account, fungibletypes.NewMsgDeployFungibleCoinZRC20( addr.String(), From df6412b16783fb2e5a0ee9f7219c54d7c92e4ee4 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Wed, 3 Jul 2024 12:56:40 -0500 Subject: [PATCH 11/37] use solana chain from merged commits --- cmd/zetaclientd/utils.go | 13 +++++++++---- e2e/runner/runner.go | 1 + e2e/txserver/zeta_tx_server.go | 4 +--- zetaclient/chains/solana/constants.go | 6 ------ zetaclient/chains/solana/observer/observer.go | 10 +++------- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/cmd/zetaclientd/utils.go b/cmd/zetaclientd/utils.go index 70752db4a5..06dd645edd 100644 --- a/cmd/zetaclientd/utils.go +++ b/cmd/zetaclientd/utils.go @@ -6,6 +6,8 @@ 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" "github.com/zeta-chain/zetacore/zetaclient/authz" "github.com/zeta-chain/zetacore/zetaclient/chains/base" @@ -15,7 +17,6 @@ import ( evmobserver "github.com/zeta-chain/zetacore/zetaclient/chains/evm/observer" evmsigner "github.com/zeta-chain/zetacore/zetaclient/chains/evm/signer" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" - "github.com/zeta-chain/zetacore/zetaclient/chains/solana" solanaobserver "github.com/zeta-chain/zetacore/zetaclient/chains/solana/observer" "github.com/zeta-chain/zetacore/zetaclient/config" "github.com/zeta-chain/zetacore/zetaclient/context" @@ -193,13 +194,17 @@ func CreateChainObserverMap( } // TODO: config this - programId := "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" - co, err := solanaobserver.NewObserver(appContext, zetacoreClient, tss, programId, dbpath, ts) + solChainParams := observertypes.ChainParams{ + GatewayAddress: "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", + IsSupported: true, + ChainId: chains.SolanaLocalnet.ChainId, + } + 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[solana.LocalnetChainID] = co + observerMap[solChainParams.ChainId] = co } return observerMap, nil diff --git a/e2e/runner/runner.go b/e2e/runner/runner.go index b8c7d2910c..99c67c9b1a 100644 --- a/e2e/runner/runner.go +++ b/e2e/runner/runner.go @@ -168,6 +168,7 @@ func NewE2ERunner( EVMAuth: evmAuth, ZEVMAuth: zevmAuth, BtcRPCClient: btcRPCClient, + SolanaClient: solanaClient, Logger: logger, } diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index 9949bfb511..6d4e1d876e 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -36,8 +36,6 @@ import ( "github.com/evmos/ethermint/crypto/hd" etherminttypes "github.com/evmos/ethermint/types" evmtypes "github.com/evmos/ethermint/x/evm/types" - solanaclient "github.com/zeta-chain/zetacore/zetaclient/chains/solana" - "github.com/zeta-chain/zetacore/app" "github.com/zeta-chain/zetacore/cmd/zetacored/config" "github.com/zeta-chain/zetacore/pkg/chains" @@ -373,7 +371,7 @@ func (zts ZetaTxServer) DeploySystemContractsAndZRC20( _, err = zts.BroadcastTx(account, fungibletypes.NewMsgDeployFungibleCoinZRC20( addr.String(), "", - solanaclient.LocalnetChainID, + chains.SolanaLocalnet.ChainId, 9, "Solana", "SOL", diff --git a/zetaclient/chains/solana/constants.go b/zetaclient/chains/solana/constants.go index ddd188ff90..ef92f06771 100644 --- a/zetaclient/chains/solana/constants.go +++ b/zetaclient/chains/solana/constants.go @@ -1,7 +1 @@ package solana - -const ( - LocalnetChainID = 28899 - TestnetChainID = 18899 - MainnetChainID = 8899 -) diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index 7bd68cf13c..e8db121165 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -19,7 +19,6 @@ import ( "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" - solanaclient "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" @@ -51,8 +50,8 @@ var _ interfaces.ChainObserver = &Observer{} func NewObserver( appContext *clientcontext.AppContext, zetacoreClient interfaces.ZetacoreClient, + chainParams observertypes.ChainParams, tss interfaces.TSSSigner, - programIdStr string, dbpath string, ts *metrics.TelemetryServer, ) (*Observer, error) { @@ -64,15 +63,12 @@ func NewObserver( ob.logger = logger //ob.coreContext = appContext.ZetacoreContext() - chainParams := observertypes.ChainParams{ - IsSupported: true, - } ob.chainParams = chainParams ob.stop = make(chan struct{}) ob.Mu = &sync.Mutex{} ob.zetacoreClient = zetacoreClient ob.Tss = tss - ob.programId = solana.MustPublicKeyFromBase58(programIdStr) + ob.programId = solana.MustPublicKeyFromBase58(chainParams.GatewayAddress) endpoint := "http://solana:8899" logger.Info().Msgf("Chain solana endpoint %s", endpoint) @@ -238,7 +234,7 @@ func (o *Observer) ObserveInbound() error { } msg := zetacore.GetInboundVoteMessage( accounts[0].String(), // check this--is this the signer? - solanaclient.LocalnetChainID, + 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, From 87b52e210c25aa7cddc891ac0d8b1c152d81547b Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Wed, 3 Jul 2024 14:24:02 -0500 Subject: [PATCH 12/37] fix solana rpc localnet config --- cmd/zetaclientd/utils.go | 2 +- cmd/zetae2e/config/clients.go | 4 ++++ cmd/zetae2e/config/config.go | 2 ++ cmd/zetae2e/config/localnet.yml | 2 ++ cmd/zetae2e/init.go | 1 + e2e/e2etests/test_solana_deposit.go | 17 ++++++++++++++++- e2e/txserver/zeta_tx_server.go | 10 ++++++++++ 7 files changed, 36 insertions(+), 2 deletions(-) diff --git a/cmd/zetaclientd/utils.go b/cmd/zetaclientd/utils.go index 06dd645edd..fb18c3e5b0 100644 --- a/cmd/zetaclientd/utils.go +++ b/cmd/zetaclientd/utils.go @@ -193,7 +193,7 @@ func CreateChainObserverMap( } } - // TODO: config this + // FIXME: config this solChainParams := observertypes.ChainParams{ GatewayAddress: "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", IsSupported: true, diff --git a/cmd/zetae2e/config/clients.go b/cmd/zetae2e/config/clients.go index 8ca92ba0d9..1c12636dea 100644 --- a/cmd/zetae2e/config/clients.go +++ b/cmd/zetae2e/config/clients.go @@ -35,7 +35,11 @@ func getClientsFromConfig(ctx context.Context, conf config.Config, account confi *bind.TransactOpts, error, ) { + if conf.RPCs.SolanaRPC == "" { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("solana rpc is empty") + } solanaClient := rpc.New(conf.RPCs.SolanaRPC) + fmt.Printf("solana client URL: %s\n", conf.RPCs.SolanaRPC) if solanaClient == nil { return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get solana client") } diff --git a/cmd/zetae2e/config/config.go b/cmd/zetae2e/config/config.go index 51c35feb11..503e1ca8a8 100644 --- a/cmd/zetae2e/config/config.go +++ b/cmd/zetae2e/config/config.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/davecgh/go-spew/spew" "github.com/zeta-chain/zetacore/e2e/config" "github.com/zeta-chain/zetacore/e2e/runner" ) @@ -18,6 +19,7 @@ func RunnerFromConfig( logger *runner.Logger, opts ...runner.E2ERunnerOption, ) (*runner.E2ERunner, error) { + spew.Dump("RunnerFromConfig conf struct", conf) // initialize clients btcRPCClient, solanaClient, diff --git a/cmd/zetae2e/config/localnet.yml b/cmd/zetae2e/config/localnet.yml index abb780db39..8324136d85 100644 --- a/cmd/zetae2e/config/localnet.yml +++ b/cmd/zetae2e/config/localnet.yml @@ -48,4 +48,6 @@ rpcs: params: regnet zetacore_grpc: "zetacore0:9090" zetacore_rpc: "http://zetacore0:26657" + solana_rpc: "http://solana:8899" + # contracts will be populated on first run \ No newline at end of file diff --git a/cmd/zetae2e/init.go b/cmd/zetae2e/init.go index 742b3ebd96..f0526188be 100644 --- a/cmd/zetae2e/init.go +++ b/cmd/zetae2e/init.go @@ -26,6 +26,7 @@ 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.ZetaChainID, "chainID", "athens_101-1", "--chainID athens_101-1") InitCmd.Flags().StringVar(&configFile, local.FlagConfigFile, "e2e.config", "--cfg ./e2e.config") diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go index b022a90f1c..67bedaaaef 100644 --- a/e2e/e2etests/test_solana_deposit.go +++ b/e2e/e2etests/test_solana_deposit.go @@ -9,6 +9,7 @@ import ( "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" ) func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { @@ -17,6 +18,20 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { } client := r.SolanaClient + //r.Logger.Print("solana client URL", client.) + if client == nil { + r.Logger.Error("Solana client is nil") + panic("Solana client is nil") + } + { + res, err := client.GetVersion(context.Background()) + if err != nil { + r.Logger.Error("error getting solana version: %v", err) + panic(err) + } + r.Logger.Print("solana RPC version: %+v", res) + } + // building the transaction recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) if err != nil { @@ -59,7 +74,7 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { inst.DataBytes, err = borsh.Serialize(InitializeParams{ Discriminator: [8]byte{175, 175, 109, 31, 13, 152, 155, 237}, TssAddress: r.TSSAddress, - ChainId: 111111, + ChainId: uint64(chains.SolanaLocalnet.ChainId), }) if err != nil { panic(err) diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index 6d4e1d876e..6f62ec0f39 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -367,6 +367,16 @@ func (zts ZetaTxServer) DeploySystemContractsAndZRC20( return "", "", "", "", "", fmt.Errorf("failed to deploy btc zrc20: %s", err.Error()) } + //chainParams := getNewEVMChainParams(newRunner) + //adminAddr, err := newRunner.ZetaTxServer.GetAccountAddressFromName(utils.FungibleAdminName) + //require.NoError(r, err) + // + //_, err = zts.BroadcastTx(utils.FungibleAdminName, observertypes.NewMsgUpdateChainParams( + // adminAddr, + // chainParams, + //)) + //require.NoError(r, err) + // deploy sol zrc20 _, err = zts.BroadcastTx(account, fungibletypes.NewMsgDeployFungibleCoinZRC20( addr.String(), From 60be21c66784278563e56690288870d07bc8f0a4 Mon Sep 17 00:00:00 2001 From: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Date: Wed, 3 Jul 2024 18:23:01 -0500 Subject: [PATCH 13/37] fix chain params for solana --- cmd/zetae2e/config/clients.go | 1 - cmd/zetae2e/config/config.go | 3 +- e2e/txserver/zeta_tx_server.go | 48 +++++++++++++++---- pkg/chains/chain.go | 7 +++ zetaclient/chains/solana/observer/observer.go | 2 +- 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/cmd/zetae2e/config/clients.go b/cmd/zetae2e/config/clients.go index 1c12636dea..c26774a330 100644 --- a/cmd/zetae2e/config/clients.go +++ b/cmd/zetae2e/config/clients.go @@ -39,7 +39,6 @@ func getClientsFromConfig(ctx context.Context, conf config.Config, account confi return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("solana rpc is empty") } solanaClient := rpc.New(conf.RPCs.SolanaRPC) - fmt.Printf("solana client URL: %s\n", conf.RPCs.SolanaRPC) if solanaClient == nil { return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get solana client") } diff --git a/cmd/zetae2e/config/config.go b/cmd/zetae2e/config/config.go index 503e1ca8a8..c3584dc720 100644 --- a/cmd/zetae2e/config/config.go +++ b/cmd/zetae2e/config/config.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/davecgh/go-spew/spew" "github.com/zeta-chain/zetacore/e2e/config" "github.com/zeta-chain/zetacore/e2e/runner" ) @@ -19,7 +18,7 @@ func RunnerFromConfig( logger *runner.Logger, opts ...runner.E2ERunnerOption, ) (*runner.E2ERunner, error) { - spew.Dump("RunnerFromConfig conf struct", conf) + //spew.Dump("RunnerFromConfig conf struct", conf) // initialize clients btcRPCClient, solanaClient, diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index 6f62ec0f39..1ccc489b09 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -2,6 +2,7 @@ package txserver import ( "context" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -189,6 +190,11 @@ func (zts ZetaTxServer) BroadcastTx(account string, msg sdktypes.Msg) (*sdktypes return nil, err } + { + tx := txBuilder.GetTx() + fmt.Printf("txBuilder.GetTx(): fee %s, gas %d", tx.GetFee().String(), tx.GetGas()) + } + // Sign tx err = tx.Sign(zts.txFactory, account, txBuilder, true) if err != nil { @@ -202,6 +208,7 @@ func (zts ZetaTxServer) BroadcastTx(account string, msg sdktypes.Msg) (*sdktypes } func broadcastWithBlockTimeout(zts ZetaTxServer, txBytes []byte) (*sdktypes.TxResponse, error) { + fmt.Printf("broadcasting tx:\n%s\n", base64.StdEncoding.EncodeToString(txBytes)) res, err := zts.clientCtx.BroadcastTx(txBytes) if err != nil { if res == nil { @@ -222,11 +229,13 @@ 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", zts.blockTimeout) + 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) + if err == nil { - return mkTxResult(zts.clientCtx, resTx) + txr, err := mkTxResult(zts.clientCtx, resTx) + return txr, err } } } @@ -367,14 +376,33 @@ func (zts ZetaTxServer) DeploySystemContractsAndZRC20( return "", "", "", "", "", fmt.Errorf("failed to deploy btc zrc20: %s", err.Error()) } - //chainParams := getNewEVMChainParams(newRunner) - //adminAddr, err := newRunner.ZetaTxServer.GetAccountAddressFromName(utils.FungibleAdminName) - //require.NoError(r, err) - // - //_, err = zts.BroadcastTx(utils.FungibleAdminName, observertypes.NewMsgUpdateChainParams( - // adminAddr, - // chainParams, - //)) + // FIXME: config this + chainParams := observertypes.ChainParams{ + ChainId: chains.SolanaLocalnet.ChainId, + IsSupported: true, + GatewayAddress: "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", + BallotThreshold: sdktypes.MustNewDecFromStr("0.66"), + ConfirmationCount: 32, + GasPriceTicker: 100, + InboundTicker: 5, + OutboundTicker: 5, + OutboundScheduleInterval: 10, + OutboundScheduleLookahead: 10, + MinObserverDelegation: sdktypes.MustNewDecFromStr("1"), + } + msg := observertypes.NewMsgUpdateChainParams( + addr.String(), + &chainParams, + ) + err = msg.ValidateBasic() + if err != nil { + return "", "", "", "", "", fmt.Errorf("failed to validate chain params: %s", err.Error()) + } + _, err = zts.BroadcastTx(account, msg) + if err != nil { + fmt.Printf("failed to update chain params: %s\n", err.Error()) + return "", "", "", "", "", fmt.Errorf("failed to update chain params (FungibleAdminName): %s", err.Error()) + } //require.NoError(r, err) // deploy sol zrc20 diff --git a/pkg/chains/chain.go b/pkg/chains/chain.go index 46ead5f9f2..472713c12e 100644 --- a/pkg/chains/chain.go +++ b/pkg/chains/chain.go @@ -93,6 +93,8 @@ func DecodeAddressFromChainID(chainID int64, addr string, additionalChains []Cha return ethcommon.HexToAddress(addr).Bytes(), nil case IsBitcoinChain(chainID, additionalChains): return []byte(addr), nil + case IsSolanaChain(chainID, additionalChains): + return []byte(addr), nil default: return nil, fmt.Errorf("chain (%d) not supported", chainID) } @@ -112,6 +114,11 @@ func IsBitcoinChain(chainID int64, additionalChains []Chain) bool { return ChainIDInChainList(chainID, ChainListByConsensus(Consensus_bitcoin, additionalChains)) } +// IsSolanaChain returns true if the chain is a Solana chain +func IsSolanaChain(chainID int64, additionalChains []Chain) bool { + return ChainIDInChainList(chainID, ChainListByConsensus(Consensus_solana_consensus, additionalChains)) +} + // IsEthereumChain returns true if the chain is an Ethereum chain // additionalChains is a list of additional chains to search from // in practice, it is used in the protocol to dynamically support new chains without doing an upgrade diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index e8db121165..fcf2556cd7 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -209,7 +209,7 @@ func (o *Observer) ObserveInbound() error { Memo []byte } transaction, _ := tx.Transaction.GetTransaction() - instruction := transaction.Message.Instructions[0] // TODO: parse not only the first instruction + 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) From 8f1648adcc1f59a4a65369752417d3566ee1d3ac Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Thu, 11 Jul 2024 17:06:27 -0500 Subject: [PATCH 14/37] initialted inbound observation on SOL deposit --- cmd/solana/main.go | 52 ++-- cmd/zetaclientd/utils.go | 41 ++- cmd/zetae2e/config/clients.go | 20 +- cmd/zetae2e/init.go | 3 +- cmd/zetae2e/local/local.go | 2 +- cmd/zetae2e/local/solana.go | 3 +- e2e/e2etests/test_solana_deposit.go | 43 ++- e2e/runner/setup_zeta.go | 6 +- e2e/runner/solana.go | 19 +- e2e/txserver/zeta_tx_server.go | 7 +- go.mod | 2 - go.sum | 4 - testutil/sample/crypto.go | 26 ++ testutil/sample/zetaclient.go | 25 ++ zetaclient/chains/base/observer.go | 105 ++++++- zetaclient/chains/base/observer_test.go | 182 +++++++++-- zetaclient/chains/evm/observer/inbound.go | 31 +- zetaclient/chains/interfaces/interfaces.go | 19 ++ zetaclient/chains/solana/constants.go | 17 + zetaclient/chains/solana/observer/db.go | 48 +++ zetaclient/chains/solana/observer/db_test.go | 104 +++++++ zetaclient/chains/solana/observer/inbound.go | 294 ++++++++++++++++++ .../chains/solana/observer/inbound_test.go | 177 +++++++++++ .../chains/solana/observer/inbound_tracker.go | 73 +++++ zetaclient/chains/solana/observer/observer.go | 290 ++++------------- zetaclient/chains/solana/observer/outbound.go | 18 ++ zetaclient/chains/solana/observer/types.go | 13 + zetaclient/chains/solana/rpc/rpc.go | 111 +++++++ zetaclient/chains/solana/rpc/rpc_live_test.go | 58 ++++ zetaclient/compliance/compliance.go | 24 ++ zetaclient/config/types.go | 16 + zetaclient/context/app.go | 11 + ...LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk.json | 64 ++++ zetaclient/testutils/testdata.go | 22 ++ zetaclient/testutils/testdata_naming.go | 11 + zetaclient/types/event.go | 42 +++ zetaclient/types/sql.go | 41 +++ zetaclient/types/sql_evm.go | 15 - 38 files changed, 1671 insertions(+), 368 deletions(-) create mode 100644 testutil/sample/zetaclient.go create mode 100644 zetaclient/chains/solana/observer/db.go create mode 100644 zetaclient/chains/solana/observer/db_test.go create mode 100644 zetaclient/chains/solana/observer/inbound.go create mode 100644 zetaclient/chains/solana/observer/inbound_test.go create mode 100644 zetaclient/chains/solana/observer/inbound_tracker.go create mode 100644 zetaclient/chains/solana/observer/outbound.go create mode 100644 zetaclient/chains/solana/observer/types.go create mode 100644 zetaclient/chains/solana/rpc/rpc.go create mode 100644 zetaclient/chains/solana/rpc/rpc_live_test.go create mode 100644 zetaclient/testdata/solana/chain_901_inbound_tx_result_5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk.json create mode 100644 zetaclient/types/event.go create mode 100644 zetaclient/types/sql.go diff --git a/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, - } -} From 46e0eac99d6f15743e6b944be759932d7b5a060f Mon Sep 17 00:00:00 2001 From: Alex Gartner Date: Fri, 12 Jul 2024 09:27:24 -0700 Subject: [PATCH 15/37] Use docker image and add make targets --- Makefile | 9 ++ contrib/localnet/docker-compose.yml | 4 +- contrib/localnet/solana/Dockerfile | 12 ++ contrib/localnet/solana/Dockerfile.solana | 134 ---------------------- 4 files changed, 24 insertions(+), 135 deletions(-) create mode 100644 contrib/localnet/solana/Dockerfile delete mode 100644 contrib/localnet/solana/Dockerfile.solana diff --git a/Makefile b/Makefile index c929aea7f8..4fdba4d882 100644 --- a/Makefile +++ b/Makefile @@ -230,6 +230,10 @@ install-zetae2e: go.sum @go install -mod=readonly $(BUILD_FLAGS) ./cmd/zetae2e .PHONY: install-zetae2e +solana: + @echo "Building solana docker image" + $(DOCKER) build -t solana-local -f contrib/localnet/solana/Dockerfile contrib/localnet/solana/ + start-e2e-test: zetanode @echo "--> Starting e2e test" cd contrib/localnet/ && $(DOCKER) compose up -d @@ -254,6 +258,11 @@ start-stress-test: zetanode @echo "--> Starting stress test" cd contrib/localnet/ && $(DOCKER) compose --profile stress -f docker-compose.yml up -d +start-solana-test: zetanode solana + @echo "--> Starting solana test" + export E2E_ARGS="--test-solana" && \ + cd contrib/localnet/ && $(DOCKER) compose --profile solana -f docker-compose.yml up -d + ############################################################################### ### Upgrade Tests ### ############################################################################### diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index ed5ec34d01..6c87ee0aa9 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -184,6 +184,9 @@ services: image: solana-local:latest container_name: solana hostname: solana + profiles: + - solana + - all ports: - "8899:8899" networks: @@ -191,7 +194,6 @@ services: ipv4_address: 172.20.0.102 entrypoint: [ "/usr/bin/start-solana.sh" ] - eth: image: ethereum/client-go:v1.10.26 container_name: eth diff --git a/contrib/localnet/solana/Dockerfile b/contrib/localnet/solana/Dockerfile new file mode 100644 index 0000000000..fec6d8c75e --- /dev/null +++ b/contrib/localnet/solana/Dockerfile @@ -0,0 +1,12 @@ +FROM ghcr.io/zeta-chain/solana-docker:1.18.15 + +WORKDIR /data +COPY ./start-solana.sh /usr/bin/start-solana.sh +RUN chmod +x /usr/bin/start-solana.sh +COPY ./gateway.so . +COPY ./gateway-keypair.json . + + + +ENTRYPOINT [ "bash" ] +CMD [ "/usr/bin/start-solana.sh" ] \ No newline at end of file diff --git a/contrib/localnet/solana/Dockerfile.solana b/contrib/localnet/solana/Dockerfile.solana deleted file mode 100644 index a969706f11..0000000000 --- a/contrib/localnet/solana/Dockerfile.solana +++ /dev/null @@ -1,134 +0,0 @@ -# Dockerfile -# solana-docker-mac-m1 -# -# Created by Raphael Tang on 12/6/2023. -# Licensed 2023 under MIT. All rights reserved. - -# ________ ________ ___ ________ ________ ________ -# |\ ____\|\ __ \|\ \ |\ __ \|\ ___ \|\ __ \ -# \ \ \___|\ \ \|\ \ \ \ \ \ \|\ \ \ \\ \ \ \ \|\ \ -# \ \_____ \ \ \\\ \ \ \ \ \ __ \ \ \\ \ \ \ __ \ -# \|____|\ \ \ \\\ \ \ \____\ \ \ \ \ \ \\ \ \ \ \ \ \ -# ____\_\ \ \_______\ \_______\ \__\ \__\ \__\\ \__\ \__\ \__\ -# |\_________\|_______|\|_______|\|__|\|__|\|__| \|__|\|__|\|__| -# \|_________| -# -# This Dockerfile contains a definition for a container which builds Solana from source -# in the build process. It should work on other operating systems too, but don't quote me on that. - -# Set directory used for all build generated files -ARG CUBICLE=/root -# Set your solana version here -ARG SOLANA_VERSION=1.18.15 -# Set folder name for intermediary files during build step -ARG BUILD_OUTPUT_DIR=${CUBICLE}/solana-output - -FROM --platform=linux/arm64 debian:stable-slim as base - -SHELL [ "/bin/bash", "-c" ] - -RUN apt update && \ - apt-get install -y \ - curl wget neovim fish \ - pkg-config bzip2 \ - && \ - rm -rf /var/lib/apt/lists/* - -# Container for building the solana binaries -FROM base as builder -ARG CUBICLE -ARG SOLANA_VERSION -ARG BUILD_OUTPUT_DIR - -RUN apt update && \ - apt-get install -y \ - build-essential \ - libssl-dev libudev-dev clang \ - gcc zlib1g-dev llvm cmake make \ - libprotobuf-dev protobuf-compiler \ - perl libfindbin-libs-perl \ - && \ - rm -rf /var/lib/apt/lists/* - -# Fetch solana source code -WORKDIR ${CUBICLE} -RUN if [[ "${SOLANA_VERSION}" == "latest" ]] ;\ - then \ - wget -O solana.tar.gz \ - https://github.com/solana-labs/solana/archive/refs/heads/master.tar.gz ;\ - else \ - wget -O solana.tar.gz \ - https://github.com/solana-labs/solana/archive/refs/tags/v${SOLANA_VERSION}.tar.gz ;\ - fi -RUN mkdir solana && \ - tar --extract --verbose --gzip --file solana.tar.gz --strip-components=1 --directory solana - -# Setup rust with compatible version -RUN source solana/ci/rust-version.sh && \ - curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain "$rust_stable" -ENV PATH=${CUBICLE}/.cargo/bin:$PATH - -# Build Solana binaries -WORKDIR ${CUBICLE}/solana -# FIXME: --validator-only is a temporary fix for -# [solana-labs/solana issue 31528](https://github.com/solana-labs/solana/issues/31528) -RUN ./scripts/cargo-install-all.sh "${BUILD_OUTPUT_DIR}" --validator-only -RUN cargo build --bin solana-test-validator --release -RUN cd "${BUILD_OUTPUT_DIR}/bin" && "${CUBICLE}/solana/fetch-spl.sh" -RUN cp -f scripts/run.sh "${BUILD_OUTPUT_DIR}/bin/run-cluster" -RUN cp -f fetch-spl.sh "${BUILD_OUTPUT_DIR}/bin/" -RUN cp -f target/release/solana-test-validator "${BUILD_OUTPUT_DIR}/bin/" - -# Final resulting multipurpose container -FROM base as final -ARG CUBICLE -ARG SOLANA_VERSION -ARG BUILD_OUTPUT_DIR - -COPY --from=builder ${BUILD_OUTPUT_DIR} /usr/ - -# RPC JSON -EXPOSE 8899/tcp -# RPC pubsub -EXPOSE 8900/tcp -# entrypoint -EXPOSE 8001/tcp -# (future) bank service -EXPOSE 8901/tcp -# bank service -EXPOSE 8902/tcp -# faucet -EXPOSE 9900/tcp -# tvu -EXPOSE 8000/udp -# gossip -EXPOSE 8001/udp -# tvu_forwards -EXPOSE 8002/udp -# tpu -EXPOSE 8003/udp -# tpu_forwards -EXPOSE 8004/udp -# retransmit -EXPOSE 8005/udp -# repair -EXPOSE 8006/udp -# serve_repair -EXPOSE 8007/udp -# broadcast -EXPOSE 8008/udp -# tpu_vote -EXPOSE 8009/udp - -RUN apt-get install -y bash - -WORKDIR /data -COPY ./start-solana.sh /usr/bin/start-solana.sh -RUN chmod +x /usr/bin/start-solana.sh -COPY ./gateway.so . -COPY ./gateway-keypair.json . - - - -ENTRYPOINT [ "bash" ] -CMD [ "/usr/bin/start-solana.sh" ] \ No newline at end of file From 25816b37b80ce1064fa4dc4192260f225d1ddbfe Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Sun, 14 Jul 2024 23:25:38 -0500 Subject: [PATCH 16/37] polish Solana initialize and deposit E2E tests --- cmd/zetae2e/config/clients.go | 4 +- cmd/zetae2e/config/localnet.yml | 6 +- cmd/zetae2e/init.go | 3 +- cmd/zetae2e/local/local.go | 2 +- cmd/zetae2e/local/solana.go | 35 +-- e2e/config/config.go | 9 +- e2e/e2etests/e2etests.go | 1 - e2e/e2etests/test_solana_deposit.go | 243 ++---------------- e2e/e2etests/test_solana_initialize.go | 73 ++++++ e2e/runner/runner.go | 9 +- e2e/runner/setup_solana.go | 25 ++ e2e/runner/solana.go | 105 +++++--- zetaclient/chains/solana/constants.go | 18 -- zetaclient/chains/solana/contract/contract.go | 38 +++ zetaclient/chains/solana/contract/types.go | 44 ++++ zetaclient/chains/solana/observer/db.go | 1 - zetaclient/chains/solana/observer/inbound.go | 17 +- zetaclient/chains/solana/observer/observer.go | 4 +- zetaclient/chains/solana/observer/types.go | 13 - zetaclient/types/event.go | 2 +- 20 files changed, 314 insertions(+), 338 deletions(-) create mode 100644 e2e/e2etests/test_solana_initialize.go create mode 100644 e2e/runner/setup_solana.go delete mode 100644 zetaclient/chains/solana/constants.go create mode 100644 zetaclient/chains/solana/contract/contract.go create mode 100644 zetaclient/chains/solana/contract/types.go delete mode 100644 zetaclient/chains/solana/observer/types.go diff --git a/cmd/zetae2e/config/clients.go b/cmd/zetae2e/config/clients.go index 7ebbee4b83..c207793739 100644 --- a/cmd/zetae2e/config/clients.go +++ b/cmd/zetae2e/config/clients.go @@ -35,10 +35,10 @@ func getClientsFromConfig(ctx context.Context, conf config.Config, account confi *bind.TransactOpts, error, ) { - if conf.RPCs.SolanaRPC == "" { + if conf.RPCs.Solana == "" { return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("solana rpc is empty") } - solanaClient := rpc.New(conf.RPCs.SolanaRPC) + solanaClient := rpc.New(conf.RPCs.Solana) if solanaClient == nil { return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get solana client") } diff --git a/cmd/zetae2e/config/localnet.yml b/cmd/zetae2e/config/localnet.yml index 8324136d85..fa7ffbc6af 100644 --- a/cmd/zetae2e/config/localnet.yml +++ b/cmd/zetae2e/config/localnet.yml @@ -20,6 +20,10 @@ additional_accounts: bech32_address: "zeta19q7czqysah6qg0n4y3l2a08gfzqxydla492v80" evm_address: "0x283d810090EdF4043E75247eAeBcE848806237fD" private_key: "7bb523963ee2c78570fb6113d886a4184d42565e8847f1cb639f5f5e2ef5b37a" + user_solana: + bech32_address: "" + evm_address: "" + private_key: "4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C" user_ether: bech32_address: "zeta134rakuus43xn63yucgxhn88ywj8ewcv6ezn2ga" evm_address: "0x8D47Db7390AC4D3D449Cc20D799ce4748F97619A" @@ -46,8 +50,8 @@ rpcs: http_post_mode: true disable_tls: true params: regnet + solana: "http://solana:8899" zetacore_grpc: "zetacore0:9090" zetacore_rpc: "http://zetacore0:26657" - solana_rpc: "http://solana:8899" # contracts will be populated on first run \ No newline at end of file diff --git a/cmd/zetae2e/init.go b/cmd/zetae2e/init.go index bb2c2b5457..1f26814d24 100644 --- a/cmd/zetae2e/init.go +++ b/cmd/zetae2e/init.go @@ -27,8 +27,7 @@ func NewInitCmd() *cobra.Command { 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") - + StringVar(&initConf.RPCs.Solana, "solanaURL", "http://solana:8899", "--solanaURL http://solana:8899") InitCmd.Flags().StringVar(&initConf.ZetaChainID, "chainID", "athens_101-1", "--chainID athens_101-1") InitCmd.Flags().StringVar(&configFile, local.FlagConfigFile, "e2e.config", "--cfg ./e2e.config") diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 61afe60207..abf8396dd5 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, testHeader, solanaTests...)) + eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, 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 405e397480..2de2be91e2 100644 --- a/cmd/zetae2e/local/solana.go +++ b/cmd/zetae2e/local/solana.go @@ -2,7 +2,6 @@ package local import ( "fmt" - "runtime" "time" "github.com/fatih/color" @@ -12,47 +11,31 @@ import ( "github.com/zeta-chain/zetacore/e2e/runner" ) -// bitcoinTestRoutine runs Bitcoin related e2e tests +// solanaTestRoutine runs Solana related e2e tests func solanaTestRoutine( conf config.Config, deployerRunner *runner.E2ERunner, verbose bool, - _ bool, testNames ...string, ) func() error { return func() (err error) { - // return an error on panic - // TODO: remove and instead return errors in the tests - // https://github.com/zeta-chain/node/issues/1500 - defer func() { - if r := recover(); r != nil { - // print stack trace - stack := make([]byte, 4096) - n := runtime.Stack(stack, false) - err = fmt.Errorf("solana panic: %v, stack trace %s", r, stack[:n]) - } - }() - account := conf.AdditionalAccounts.UserBitcoin - - // initialize runner for bitcoin test + // initialize runner for solana test solanaRunner, err := initTestRunner( "solana", conf, deployerRunner, - account, + conf.AdditionalAccounts.UserSolana, runner.NewLogger(verbose, color.FgCyan, "solana"), ) - if err != nil { return err } solanaRunner.Logger.Print("🏃 starting Solana tests") startTime := time.Now() + solanaRunner.SetupSolanaAccount() - // run bitcoin test - // Note: due to the extensive block generation in Bitcoin localnet, block header test is run first - // to make it faster to catch up with the latest block header + // run solana test testsToRun, err := solanaRunner.GetE2ETestsToRunByName( e2etests.AllE2ETests, testNames..., @@ -65,11 +48,11 @@ func solanaTestRoutine( return fmt.Errorf("solana tests failed: %v", err) } - if err := solanaRunner.CheckBtcTSSBalance(); err != nil { - return err - } + // if err := solanaRunner.CheckSolanaTSSBalance(); err != nil { + // return err + // } - solanaRunner.Logger.Print("🍾 Solana tests completed in %s", time.Since(startTime).String()) + solanaRunner.Logger.Print("🍾 solana tests completed in %s", time.Since(startTime).String()) return err } diff --git a/e2e/config/config.go b/e2e/config/config.go index 5f69f2470e..8698bb91a4 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -61,6 +61,7 @@ type AdditionalAccounts struct { UserZetaTest Account `yaml:"user_zeta_test"` UserZEVMMPTest Account `yaml:"user_zevm_mp_test"` UserBitcoin Account `yaml:"user_bitcoin"` + UserSolana Account `yaml:"user_solana"` UserEther Account `yaml:"user_ether"` UserMisc Account `yaml:"user_misc"` UserAdmin Account `yaml:"user_admin"` @@ -72,9 +73,9 @@ type RPCs struct { Zevm string `yaml:"zevm"` EVM string `yaml:"evm"` Bitcoin BitcoinRPC `yaml:"bitcoin"` + Solana string `yaml:"solana"` ZetaCoreGRPC string `yaml:"zetacore_grpc"` ZetaCoreRPC string `yaml:"zetacore_rpc"` - SolanaRPC string `yaml:"solana_rpc"` } // BitcoinRPC contains the configuration for the Bitcoin RPC endpoint @@ -139,7 +140,7 @@ func DefaultConfig() Config { }, ZetaCoreGRPC: "zetacore0:9090", ZetaCoreRPC: "http://zetacore0:26657", - SolanaRPC: "http://solana:8899", + Solana: "http://solana:8899", }, ZetaChainID: "athens_101-1", Contracts: Contracts{ @@ -253,6 +254,10 @@ func (c *Config) GenerateKeys() error { if err != nil { return err } + c.AdditionalAccounts.UserSolana, err = generateAccount() + if err != nil { + return err + } c.AdditionalAccounts.UserEther, err = generateAccount() if err != nil { return err diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index b18826c00f..460e8fc312 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -346,7 +346,6 @@ var AllE2ETests = []runner.E2ETest{ }, TestSolanaDeposit, ), - /* Bitcoin tests */ diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go index 9dd953bc6e..cffcb89361 100644 --- a/e2e/e2etests/test_solana_deposit.go +++ b/e2e/e2etests/test_solana_deposit.go @@ -1,173 +1,25 @@ package e2etests import ( - "context" - "fmt" - "time" - "github.com/gagliardetto/solana-go" - "github.com/gagliardetto/solana-go/rpc" "github.com/near/borsh-go" + "github.com/stretchr/testify/require" "github.com/zeta-chain/zetacore/e2e/runner" - "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/e2e/utils" + crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" + solanacontract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" ) -func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { - if len(args) != 0 { - panic("TestSolanaIntializeGateway requires exactly zero argument for the amount.") - } - - client := r.SolanaClient - //r.Logger.Print("solana client URL", client.) - if client == nil { - r.Logger.Error("Solana client is nil") - panic("Solana client is nil") - } - { - res, err := client.GetVersion(context.Background()) - if err != nil { - r.Logger.Error("error getting solana version: %v", err) - panic(err) - } - r.Logger.Print("solana RPC version: %+v", res) - } - - // building the transaction - recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) - if err != nil { - panic(err) - } - r.Logger.Print("recent blockhash: %s", recent.Value.Blockhash) - - programID := solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") - seed := []byte("meta") - 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", - ) - r.Logger.Print("user pubkey: %s", privkey.PublicKey().String()) - bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) - if err != nil { - panic(err) - } - r.Logger.Print("account balance in SOL %f:", float64(bal.Value)/1e9) - - var inst solana.GenericInstruction - accountSlice := []*solana.AccountMeta{} - accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) - accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) - accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) - accountSlice = append(accountSlice, solana.Meta(programID)) - inst.ProgID = programID - inst.AccountValues = accountSlice - - type InitializeParams struct { - Discriminator [8]byte - TssAddress [20]byte - 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), - }) - if err != nil { - panic(err) - } - - tx, err := solana.NewTransaction( - []solana.Instruction{&inst}, - recent.Value.Blockhash, - solana.TransactionPayer(privkey.PublicKey()), - ) - if err != nil { - panic(err) - } - _, err = tx.Sign( - func(key solana.PublicKey) *solana.PrivateKey { - if privkey.PublicKey().Equals(key) { - return &privkey - } - return nil - }, - ) - if err != nil { - panic(fmt.Errorf("unable to sign transaction: %w", err)) - } - sig, err := client.SendTransactionWithOpts( - context.TODO(), - tx, - rpc.TransactionOpts{}, - ) - if err != nil { - panic(err) - } - r.Logger.Print("broadcast success! tx sig %s; waiting for confirmation...", sig) - time.Sleep(16 * time.Second) - type PdaInfo struct { - Discriminator [8]byte - Nonce uint64 - TssAddress [20]byte - Authority [32]byte - } - pdaInfo, err := client.GetAccountInfo(context.TODO(), pdaComputed) - if err != nil { - r.Logger.Print("error getting PDA info: %v", err) - panic(err) - } - - // deserialize the PDA info - var pda PdaInfo - 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, _ []string) { - client := r.SolanaClient - - privkey := solana.MustPrivateKeyFromBase58( - "4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C", - ) - - // build & bcast a Depsosit tx - bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) - if err != nil { - r.Logger.Error("Error getting balance: %v", err) - panic(fmt.Sprintf("Error getting balance: %v", err)) - } - r.Logger.Print("account balance in SOL %f", float64(bal.Value)/1e9) - - // building the transaction - recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) - if err != nil { - r.Logger.Error("Error getting recent blockhash: %v", err) - panic(err) - } - r.Logger.Print("recent blockhash: %s", recent.Value.Blockhash) + // load deployer private key + privkey := solana.MustPrivateKeyFromBase58(r.Account.RawPrivateKey.String()) - programID := solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") - seed := []byte("meta") - pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programID) - if err != nil { - r.Logger.Error("Error finding program address: %v", err) - panic(err) - } - r.Logger.Print("computed pda: %s, bump %d\n", pdaComputed, bump) + // compute the gateway PDA address + pdaComputed := r.ComputePdaAddress() + programID := r.GatewayProgramID() - //pdaAccount := solana.MustPublicKeyFromBase58("4hA43LCh2Utef8EwCyWwYmWBoSeNq6RS2HdoLkWGm5z5") + // create 'deposit' instruction var inst solana.GenericInstruction accountSlice := []*solana.AccountMeta{} accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) @@ -177,75 +29,22 @@ func TestSolanaDeposit(r *runner.E2ERunner, _ []string) { inst.ProgID = programID inst.AccountValues = accountSlice - type DepositInstructionParams struct { - Discriminator [8]byte - Amount uint64 - Memo []byte - } - - inst.DataBytes, err = borsh.Serialize(DepositInstructionParams{ - Discriminator: [8]byte{0xf2, 0x23, 0xc6, 0x89, 0x52, 0xe1, 0xf2, 0xb6}, + var err error + inst.DataBytes, err = borsh.Serialize(solanacontract.DepositInstructionParams{ + Discriminator: solanacontract.DiscriminatorDeposit(), Amount: 1338, Memo: []byte("hello this is a good memo for you to enjoy"), }) - if err != nil { - r.Logger.Error("Error serializing deposit instruction: %v", err) - panic(err) - } - - tx, err := solana.NewTransaction( - []solana.Instruction{&inst}, - recent.Value.Blockhash, - solana.TransactionPayer(privkey.PublicKey()), - ) - if err != nil { - r.Logger.Error("Error creating transaction: %v", err) - panic(err) - } - _, err = tx.Sign( - func(key solana.PublicKey) *solana.PrivateKey { - if privkey.PublicKey().Equals(key) { - return &privkey - } - return nil - }, - ) - if err != nil { - r.Logger.Error("Error signing transaction: %v", err) - panic(fmt.Errorf("unable to sign transaction: %w", err)) - } - - //spew.Dump(tx) + require.NoError(r, err) - sig, err := client.SendTransactionWithOpts( - context.TODO(), - tx, - rpc.TransactionOpts{}, - ) - if err != nil { - r.Logger.Error("Error sending transaction: %v", err) - panic(err) - } - r.Logger.Print("broadcast success! tx sig %s; waiting for confirmation...", sig) - time.Sleep(16 * time.Second) + // create and sign the transaction + signedTx := r.CreateSignedTransaction([]solana.Instruction{&inst}, privkey) - //spew.Dump(sig) - out, err := client.GetTransaction(context.TODO(), sig, &rpc.GetTransactionOpts{}) - if err != nil { - r.Logger.Error("Error getting transaction: %v", err) - panic(err) - } - r.Logger.Print("transaction status: %v, %v", out.Meta.Err, out.Meta.Status) - r.Logger.Print("transaction logs: %v", out.Meta.LogMessages) + // broadcast the transaction and wait for finalization + sig, out := r.BroadcastTxSync(signedTx) + r.Logger.Print("deposit logs: %v", out.Meta.LogMessages) // wait for the cctx to be mined - //cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, txHash.String(), r.CctxClient, r.Logger, r.CctxTimeout) - //r.Logger.CCTX(*cctx, "deposit") - //if cctx.CctxStatus.Status != crosschaintypes.CctxStatus_OutboundMined { - // panic(fmt.Sprintf( - // "expected mined status; got %s, message: %s", - // cctx.CctxStatus.Status.String(), - // cctx.CctxStatus.StatusMessage), - // ) - //} + cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, sig.String(), r.CctxClient, r.Logger, r.CctxTimeout) + utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) } diff --git a/e2e/e2etests/test_solana_initialize.go b/e2e/e2etests/test_solana_initialize.go new file mode 100644 index 0000000000..50ddfd7b5c --- /dev/null +++ b/e2e/e2etests/test_solana_initialize.go @@ -0,0 +1,73 @@ +package e2etests + +import ( + "context" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/zetacore/e2e/runner" + "github.com/zeta-chain/zetacore/pkg/chains" + solanacontract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" +) + +func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { + // no arguments expected + require.Len(r, args, 0, "solana gateway initialization test should have no arguments") + + // print the solana node version + client := r.SolanaClient + res, err := client.GetVersion(context.Background()) + require.NoError(r, err) + r.Logger.Print("solana version: %+v", res) + + // get deployer account balance + privkey := solana.MustPrivateKeyFromBase58(r.Account.RawPrivateKey.String()) + r.Logger.Print("deployer pubkey: %s", privkey.PublicKey().String()) + bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) + require.NoError(r, err) + r.Logger.Print("deployer balance in SOL %f:", float64(bal.Value)/1e9) + + // compute the gateway PDA address + pdaComputed := r.ComputePdaAddress() + programID := r.GatewayProgramID() + + // create 'initialize' instruction + var inst solana.GenericInstruction + accountSlice := []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + accountSlice = append(accountSlice, solana.Meta(programID)) + inst.ProgID = programID + inst.AccountValues = accountSlice + r.Logger.Print("TSS EthAddress: %s", r.TSSAddress) + + inst.DataBytes, err = borsh.Serialize(solanacontract.InitializeParams{ + Discriminator: solanacontract.DiscriminatorInitialize(), + TssAddress: r.TSSAddress, + ChainID: uint64(chains.SolanaLocalnet.ChainId), + }) + require.NoError(r, err) + + // create and sign the transaction + signedTx := r.CreateSignedTransaction([]solana.Instruction{&inst}, privkey) + + // broadcast the transaction and wait for finalization + _, out := r.BroadcastTxSync(signedTx) + r.Logger.Print("initialize logs: %v", out.Meta.LogMessages) + + // retrieve the PDA account info + pdaInfo, err := client.GetAccountInfo(context.TODO(), pdaComputed) + require.NoError(r, err) + + // deserialize the PDA info + pda := solanacontract.PdaInfo{} + err = borsh.Deserialize(&pda, pdaInfo.Bytes()) + require.NoError(r, err) + tssAddress := ethcommon.BytesToAddress(pda.TssAddress[:]) + r.Logger.Print("PDA info Tss: %v", tssAddress) +} diff --git a/e2e/runner/runner.go b/e2e/runner/runner.go index 99c67c9b1a..3a57e382f7 100644 --- a/e2e/runner/runner.go +++ b/e2e/runner/runner.go @@ -52,10 +52,11 @@ func WithZetaTxServer(txServer *txserver.ZetaTxServer) E2ERunnerOption { // It also provides some helper functions type E2ERunner struct { // accounts - Account config.Account - TSSAddress ethcommon.Address - BTCTSSAddress btcutil.Address - BTCDeployerAddress *btcutil.AddressWitnessPubKeyHash + Account config.Account + TSSAddress ethcommon.Address + BTCTSSAddress btcutil.Address + BTCDeployerAddress *btcutil.AddressWitnessPubKeyHash + SolanaDeployerAddress solana.PublicKey // rpc clients ZEVMClient *ethclient.Client diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go new file mode 100644 index 0000000000..c49f0809d3 --- /dev/null +++ b/e2e/runner/setup_solana.go @@ -0,0 +1,25 @@ +package runner + +import ( + "time" + + "github.com/gagliardetto/solana-go" +) + +func (r *E2ERunner) SetupSolanaAccount() { + r.Logger.Print("⚙️ setting up Solana account") + startTime := time.Now() + defer func() { + r.Logger.Print("✅ Solana account setup in %s\n", time.Since(startTime)) + }() + + r.SetSolanaAddress() +} + +// SetSolanaAddress imports the deployer's private key +func (r *E2ERunner) SetSolanaAddress() { + privateKey := solana.MustPrivateKeyFromBase58(r.Account.RawPrivateKey.String()) + r.SolanaDeployerAddress = privateKey.PublicKey() + + r.Logger.Info("SolanaDeployerAddress: %s", r.BTCDeployerAddress.EncodeAddress()) +} diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go index a90f8c83e3..42ee4ea260 100644 --- a/e2e/runner/solana.go +++ b/e2e/runner/solana.go @@ -1,51 +1,84 @@ package runner import ( - "fmt" + "context" + "time" - "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/require" - zetabitcoin "github.com/zeta-chain/zetacore/zetaclient/chains/bitcoin" + solanacontract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" ) -// DepositSolWithAmount deposits Sol on ZetaChain with a specific amount -func (r *E2ERunner) DepositSolWithAmount(amount float64) (txHash *chainhash.Hash) { - r.Logger.Print("⏳ depositing Sol into ZEVM") +// GatewayProgramID is the program ID for the gateway program +func (r *E2ERunner) GatewayProgramID() solana.PublicKey { + return solana.MustPublicKeyFromBase58(solanacontract.GatewayProgramID) +} - // list deployer utxos - utxos, err := r.ListDeployerUTXOs() - if err != nil { - panic(err) - } +// ComputePdaAddress computes the PDA address for the gateway program +func (r *E2ERunner) ComputePdaAddress() solana.PublicKey { + seed := []byte(solanacontract.PDASeed) + GatewayProgramID := solana.MustPublicKeyFromBase58(solanacontract.GatewayProgramID) + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, GatewayProgramID) + require.NoError(r, err) - spendableAmount := 0.0 - spendableUTXOs := 0 - for _, utxo := range utxos { - if utxo.Spendable { - spendableAmount += utxo.Amount - spendableUTXOs++ - } - } + r.Logger.Info("computed pda: %s, bump %d\n", pdaComputed, bump) - if spendableAmount < amount { - panic(fmt.Errorf( - "not enough spendable BTC to run the test; have %f, require %f", - spendableAmount, - amount, - )) - } + return pdaComputed +} - 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...") +// CreateSignedTransaction creates a signed transaction from instructions +func (r *E2ERunner) CreateSignedTransaction( + instructions []solana.Instruction, + privateKey solana.PrivateKey, +) *solana.Transaction { + // get a recent blockhash + recent, err := r.SolanaClient.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) + require.NoError(r, err) - amount = amount + zetabitcoin.DefaultDepositorFee - txHash, err = r.SendToTSSFromDeployerToDeposit(amount, utxos) - if err != nil { - panic(err) + // create the initialize transaction + tx, err := solana.NewTransaction( + instructions, + recent.Value.Blockhash, + solana.TransactionPayer(privateKey.PublicKey()), + ) + require.NoError(r, err) + + // sign the initialize transaction + _, err = tx.Sign( + func(key solana.PublicKey) *solana.PrivateKey { + if privateKey.PublicKey().Equals(key) { + return &privateKey + } + return nil + }, + ) + require.NoError(r, err) + + return tx +} + +// BroadcastTxSync broadcasts a transaction and waits for it to be finalized +func (r *E2ERunner) BroadcastTxSync(tx *solana.Transaction) (solana.Signature, *rpc.GetTransactionResult) { + // broadcast the transaction + sig, err := r.SolanaClient.SendTransactionWithOpts( + context.TODO(), + tx, + rpc.TransactionOpts{}, + ) + require.NoError(r, err) + r.Logger.Info("broadcast success! tx sig %s; waiting for confirmation...", sig) + + // wait for the transaction to be finalized + var out *rpc.GetTransactionResult + for { + time.Sleep(1 * time.Second) + out, err = r.SolanaClient.GetTransaction(context.TODO(), sig, &rpc.GetTransactionOpts{}) + if err == nil { + break + } } - r.Logger.Info("send BTC to TSS txHash: %s", txHash.String()) - return txHash + return sig, out } diff --git a/zetaclient/chains/solana/constants.go b/zetaclient/chains/solana/constants.go deleted file mode 100644 index d8d500a899..0000000000 --- a/zetaclient/chains/solana/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -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/contract/contract.go b/zetaclient/chains/solana/contract/contract.go new file mode 100644 index 0000000000..a8e61a369f --- /dev/null +++ b/zetaclient/chains/solana/contract/contract.go @@ -0,0 +1,38 @@ +package contract + +const ( + // GatewayProgramID is the program ID of the Solana gateway program + GatewayProgramID = "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" + + // PDASeed is the seed for the Solana gateway program derived address + PDASeed = "meta" + + // AccountsNumberOfDeposit is the number of accounts required for Solana gateway deposit instruction + // [signer, pda, system_program, gateway_program] + AccountsNumDeposit = 4 +) + +// DiscriminatorInitialize returns the discriminator for Solana gateway 'initialize' instruction +func DiscriminatorInitialize() [8]byte { + return [8]byte{175, 175, 109, 31, 13, 152, 155, 237} +} + +// DiscriminatorDeposit returns the discriminator for Solana gateway 'deposit' instruction +func DiscriminatorDeposit() [8]byte { + return [8]byte{242, 35, 198, 137, 82, 225, 242, 182} +} + +// DiscriminatorDepositSPL returns the discriminator for Solana gateway 'deposit_spl_token' instruction +func DiscriminatorDepositSPL() [8]byte { + return [8]byte{86, 172, 212, 121, 63, 233, 96, 144} +} + +// DiscriminatorWithdraw returns the discriminator for Solana gateway 'withdraw' instruction +func DiscriminatorWithdraw() [8]byte { + return [8]byte{183, 18, 70, 156, 148, 109, 161, 34} +} + +// DiscriminatorWithdrawSPL returns the discriminator for Solana gateway 'withdraw_spl_token' instruction +func DiscriminatorWithdrawSPL() [8]byte { + return [8]byte{156, 234, 11, 89, 235, 246, 32} +} diff --git a/zetaclient/chains/solana/contract/types.go b/zetaclient/chains/solana/contract/types.go new file mode 100644 index 0000000000..d75845ff0f --- /dev/null +++ b/zetaclient/chains/solana/contract/types.go @@ -0,0 +1,44 @@ +package contract + +// PdaInfo represents the PDA for the gateway program +type PdaInfo struct { + // Discriminator is the unique identifier for the PDA + Discriminator [8]byte + + // Nonce is the current nonce for the PDA + Nonce uint64 + + // TssAddress is the TSS address for the PDA + TssAddress [20]byte + + // Authority is the authority for the PDA + Authority [32]byte + + // ChainId is the chain ID for the gateway program + // TODO: this field exists in latest version of gateway program, but not in the current e2e test program + // ChainId uint64 +} + +// InitializeParams contains the parameters for a gateway initialize instruction +type InitializeParams struct { + // Discriminator is the unique identifier for the initialize instruction + Discriminator [8]byte + + // TssAddress is the TSS address + TssAddress [20]byte + + // ChainID is the chain ID for the gateway program + ChainID uint64 +} + +// DepositInstructionParams contains the parameters for a gateway deposit instruction +type DepositInstructionParams struct { + // Discriminator is the unique identifier for the deposit instruction + Discriminator [8]byte + + // Amount is the lamports amount for the deposit + Amount uint64 + + // Memo is the memo for the deposit + Memo []byte +} diff --git a/zetaclient/chains/solana/observer/db.go b/zetaclient/chains/solana/observer/db.go index 3e41b27f03..39edbd03f7 100644 --- a/zetaclient/chains/solana/observer/db.go +++ b/zetaclient/chains/solana/observer/db.go @@ -25,7 +25,6 @@ func (ob *Observer) LoadDB(dbPath string) error { } // 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) diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index 7906b15088..4d37608b39 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -16,13 +16,18 @@ import ( "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" + contract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" 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" ) +const ( + // MaxSignaturesPerTicker is the maximum number of signatures to process on a ticker + MaxSignaturesPerTicker = 100 +) + // 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. @@ -104,7 +109,7 @@ func (ob *Observer) ObserveInbound(sampledLogger zerolog.Logger) error { 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 { + if len(signatures)-i >= MaxSignaturesPerTicker { break } } @@ -219,14 +224,14 @@ func (ob *Observer) ParseInboundAsDeposit( instruction := tx.Message.Instructions[instructionIndex] // try deserializing instruction as a 'deposit' - var inst DepositInstructionParams + var inst contract.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()) { + if inst.Discriminator == contract.DiscriminatorDeposit() { return nil, nil } @@ -258,8 +263,8 @@ func (ob *Observer) ParseInboundAsDeposit( // 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)) + if len(inst.Accounts) != contract.AccountsNumDeposit { + return "", fmt.Errorf("want %d accounts, got %d", contract.AccountsNumDeposit, len(inst.Accounts)) } // the accounts are [signer, pda, system_program, gateway_program] diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index 465a5ff4aa..2341a13c15 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -7,7 +7,7 @@ import ( 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" + contract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" clientcontext "github.com/zeta-chain/zetacore/zetaclient/context" "github.com/zeta-chain/zetacore/zetaclient/metrics" ) @@ -64,7 +64,7 @@ func NewObserver( } // compute gateway PDA - seed := []byte(solanachain.PDASeed) + seed := []byte(contract.PDASeed) ob.pdaID, _, err = solana.FindProgramAddress([][]byte{seed}, ob.gatewayID) if err != nil { return nil, err diff --git a/zetaclient/chains/solana/observer/types.go b/zetaclient/chains/solana/observer/types.go deleted file mode 100644 index 8214b016a7..0000000000 --- a/zetaclient/chains/solana/observer/types.go +++ /dev/null @@ -1,13 +0,0 @@ -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/types/event.go b/zetaclient/types/event.go index 7551f34b1a..8bff59c5b5 100644 --- a/zetaclient/types/event.go +++ b/zetaclient/types/event.go @@ -19,7 +19,7 @@ type InboundEvent struct { // TxOrigin is the origin of the transaction TxOrigin string - // Value is the amount of SOL/SPL token + // Value is the amount of token Amount uint64 // Memo is the memo attached to the inbound From b3f277ef5e66b22d3e2662a76104095a2319c1b0 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Mon, 15 Jul 2024 22:39:54 -0500 Subject: [PATCH 17/37] make Solana inbound e2e test passing --- cmd/zetaclientd/utils.go | 1 + cmd/zetae2e/config/localnet.yml | 7 ++-- contrib/localnet/docker-compose.yml | 3 -- e2e/config/config.go | 7 ++-- e2e/e2etests/test_solana_deposit.go | 7 ++-- e2e/e2etests/test_solana_initialize.go | 2 +- e2e/runner/setup_solana.go | 4 +- e2e/txserver/zeta_tx_server.go | 7 ---- pkg/bg/bg.go | 15 ++++++++ zetaclient/chains/solana/observer/db.go | 21 +--------- zetaclient/chains/solana/observer/db_test.go | 8 ++-- zetaclient/chains/solana/observer/inbound.go | 38 ++++++++++++------- .../chains/solana/observer/inbound_test.go | 20 ++++++---- zetaclient/chains/solana/observer/observer.go | 7 ++++ zetaclient/chains/solana/rpc/rpc.go | 5 +++ zetaclient/config/types.go | 6 ++- 16 files changed, 92 insertions(+), 66 deletions(-) diff --git a/cmd/zetaclientd/utils.go b/cmd/zetaclientd/utils.go index 482678023b..3e1a444889 100644 --- a/cmd/zetaclientd/utils.go +++ b/cmd/zetaclientd/utils.go @@ -226,6 +226,7 @@ func CreateChainObserverMap( solChainParams, zetacoreClient, tss, + dbpath, logger, ts, ) diff --git a/cmd/zetae2e/config/localnet.yml b/cmd/zetae2e/config/localnet.yml index 2750db3305..1c082d0f4e 100644 --- a/cmd/zetae2e/config/localnet.yml +++ b/cmd/zetae2e/config/localnet.yml @@ -21,9 +21,10 @@ additional_accounts: evm_address: "0x283d810090EdF4043E75247eAeBcE848806237fD" private_key: "7bb523963ee2c78570fb6113d886a4184d42565e8847f1cb639f5f5e2ef5b37a" user_solana: - bech32_address: "" - evm_address: "" - private_key: "4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C" + bech32_address: "zeta1zqlajgj0qr8rqylf2c572t0ux8vqt45d4zngpm" + evm_address: "0x103FD9224F00ce3013e95629e52DFc31D805D68d" + private_key: "dd53f191113d18e57bd4a5494a64a020ba7919c815d0a6d34a42ebb2839e9d95" + base58_private_key: "4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C" user_ether: bech32_address: "zeta134rakuus43xn63yucgxhn88ywj8ewcv6ezn2ga" evm_address: "0x8D47Db7390AC4D3D449Cc20D799ce4748F97619A" diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index 6c87ee0aa9..f3c42b9a73 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -184,9 +184,6 @@ services: image: solana-local:latest container_name: solana hostname: solana - profiles: - - solana - - all ports: - "8899:8899" networks: diff --git a/e2e/config/config.go b/e2e/config/config.go index f34a48ed71..c2769c00e9 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -51,9 +51,10 @@ type Config struct { // Account contains configuration for an account type Account struct { - RawBech32Address DoubleQuotedString `yaml:"bech32_address"` - RawEVMAddress DoubleQuotedString `yaml:"evm_address"` - RawPrivateKey DoubleQuotedString `yaml:"private_key"` + RawBech32Address DoubleQuotedString `yaml:"bech32_address"` + RawEVMAddress DoubleQuotedString `yaml:"evm_address"` + RawPrivateKey DoubleQuotedString `yaml:"private_key"` + RawBase58PrivateKey DoubleQuotedString `yaml:"base58_private_key"` } // AdditionalAccounts are extra accounts required to run specific tests diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go index cffcb89361..10033697c9 100644 --- a/e2e/e2etests/test_solana_deposit.go +++ b/e2e/e2etests/test_solana_deposit.go @@ -13,7 +13,7 @@ import ( func TestSolanaDeposit(r *runner.E2ERunner, _ []string) { // load deployer private key - privkey := solana.MustPrivateKeyFromBase58(r.Account.RawPrivateKey.String()) + privkey := solana.MustPrivateKeyFromBase58(r.Account.RawBase58PrivateKey.String()) // compute the gateway PDA address pdaComputed := r.ComputePdaAddress() @@ -32,8 +32,8 @@ func TestSolanaDeposit(r *runner.E2ERunner, _ []string) { var err error inst.DataBytes, err = borsh.Serialize(solanacontract.DepositInstructionParams{ Discriminator: solanacontract.DiscriminatorDeposit(), - Amount: 1338, - Memo: []byte("hello this is a good memo for you to enjoy"), + Amount: 13370000, + Memo: r.EVMAddress().Bytes(), }) require.NoError(r, err) @@ -42,6 +42,7 @@ func TestSolanaDeposit(r *runner.E2ERunner, _ []string) { // broadcast the transaction and wait for finalization sig, out := r.BroadcastTxSync(signedTx) + r.Logger.Print("deposit receiver address: %s", r.EVMAddress().String()) r.Logger.Print("deposit logs: %v", out.Meta.LogMessages) // wait for the cctx to be mined diff --git a/e2e/e2etests/test_solana_initialize.go b/e2e/e2etests/test_solana_initialize.go index 50ddfd7b5c..c40dd0c79e 100644 --- a/e2e/e2etests/test_solana_initialize.go +++ b/e2e/e2etests/test_solana_initialize.go @@ -25,7 +25,7 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { r.Logger.Print("solana version: %+v", res) // get deployer account balance - privkey := solana.MustPrivateKeyFromBase58(r.Account.RawPrivateKey.String()) + privkey := solana.MustPrivateKeyFromBase58(r.Account.RawBase58PrivateKey.String()) r.Logger.Print("deployer pubkey: %s", privkey.PublicKey().String()) bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) require.NoError(r, err) diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index c49f0809d3..4ff78c989a 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -18,8 +18,8 @@ func (r *E2ERunner) SetupSolanaAccount() { // SetSolanaAddress imports the deployer's private key func (r *E2ERunner) SetSolanaAddress() { - privateKey := solana.MustPrivateKeyFromBase58(r.Account.RawPrivateKey.String()) + privateKey := solana.MustPrivateKeyFromBase58(r.Account.RawBase58PrivateKey.String()) r.SolanaDeployerAddress = privateKey.PublicKey() - r.Logger.Info("SolanaDeployerAddress: %s", r.BTCDeployerAddress.EncodeAddress()) + r.Logger.Info("SolanaDeployerAddress: %s", r.SolanaDeployerAddress) } diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index 9df5bf76f8..b1a8313174 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -2,7 +2,6 @@ package txserver import ( "context" - "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -205,11 +204,6 @@ func (zts ZetaTxServer) BroadcastTx(account string, msg sdktypes.Msg) (*sdktypes return nil, err } - { - tx := txBuilder.GetTx() - fmt.Printf("txBuilder.GetTx(): fee %s, gas %d", tx.GetFee().String(), tx.GetGas()) - } - // Sign tx err = tx.Sign(zts.txFactory, account, txBuilder, true) if err != nil { @@ -223,7 +217,6 @@ func (zts ZetaTxServer) BroadcastTx(account string, msg sdktypes.Msg) (*sdktypes } func broadcastWithBlockTimeout(zts ZetaTxServer, txBytes []byte) (*sdktypes.TxResponse, error) { - fmt.Printf("broadcasting tx:\n%s\n", base64.StdEncoding.EncodeToString(txBytes)) res, err := zts.clientCtx.BroadcastTx(txBytes) if err != nil { if res == nil { diff --git a/pkg/bg/bg.go b/pkg/bg/bg.go index 85d85964cf..2c037c33be 100644 --- a/pkg/bg/bg.go +++ b/pkg/bg/bg.go @@ -4,6 +4,7 @@ package bg import ( "context" "fmt" + "runtime" "github.com/rs/zerolog" ) @@ -39,6 +40,7 @@ func Work(ctx context.Context, f func(context.Context) error, opts ...Opt) { if r := recover(); r != nil { err := fmt.Errorf("recovered from PANIC in background task: %v", r) logError(err, cfg) + printStack() } }() @@ -60,3 +62,16 @@ func logError(err error, cfg config) { cfg.logger.Error().Err(err).Str("worker.name", name).Msgf("Background task failed") } + +func printStack() { + buf := make([]byte, 1024) + for { + n := runtime.Stack(buf, false) + if n < len(buf) { + buf = buf[:n] + break + } + buf = make([]byte, 2*len(buf)) + } + fmt.Printf("Stack trace:\n%s\n", string(buf)) +} diff --git a/zetaclient/chains/solana/observer/db.go b/zetaclient/chains/solana/observer/db.go index 39edbd03f7..1030bdbe99 100644 --- a/zetaclient/chains/solana/observer/db.go +++ b/zetaclient/chains/solana/observer/db.go @@ -2,8 +2,6 @@ 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 @@ -18,29 +16,14 @@ func (ob *Observer) LoadDB(dbPath string) error { return errors.Wrapf(err, "error OpenDB for chain %d", ob.Chain().ChainId) } - // load last scanned tx - err = ob.LoadLastTxScanned() + ob.Observer.LoadLastTxScanned(ob.Logger().Chain) - return err + return nil } // LoadLastTxScanned loads the last scanned tx from the database. 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 index c36e28b914..f6fdee1b73 100644 --- a/zetaclient/chains/solana/observer/db_test.go +++ b/zetaclient/chains/solana/observer/db_test.go @@ -23,6 +23,7 @@ func MockSolanaObserver( chainParams observertypes.ChainParams, zetacoreClient interfaces.ZetacoreClient, tss interfaces.TSSSigner, + dbpath string, ) *observer.Observer { // use mock zetacore client if not provided if zetacoreClient == nil { @@ -40,6 +41,7 @@ func MockSolanaObserver( chainParams, zetacoreClient, tss, + dbpath, base.DefaultLogger(), nil, ) @@ -56,8 +58,7 @@ func Test_LoadDB(t *testing.T) { dbpath := sample.CreateTempDir(t) // create observer - ob := MockSolanaObserver(t, chain, nil, *params, nil, nil) - ob.OpenDB(dbpath, "") + ob := MockSolanaObserver(t, chain, nil, *params, nil, nil, dbpath) // write last tx to db lastTx := sample.SolanaSignature(t).String() @@ -87,8 +88,7 @@ func Test_LoadLastTxScanned(t *testing.T) { dbpath := sample.CreateTempDir(t) // create observer - ob := MockSolanaObserver(t, chain, nil, *params, nil, nil) - ob.OpenDB(dbpath, "") + ob := MockSolanaObserver(t, chain, nil, *params, nil, nil, dbpath) t.Run("should load last block scanned", func(t *testing.T) { // write sample last tx to db diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index 6237bf743a..1fd82c2aae 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -30,8 +30,6 @@ const ( ) // 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(ctx context.Context) error { app, err := zctx.FromContext(ctx) if err != nil { @@ -59,7 +57,7 @@ func (ob *Observer) WatchInbound(ctx context.Context) error { Msgf("WatchInbound: inbound observation is disabled for chain %d", ob.Chain().ChainId) continue } - err := ob.ObserveInbound(ctx, sampledLogger) + err := ob.ObserveInbound(ctx) if err != nil { ob.Logger().Inbound.Err(err).Msg("WatchInbound: observeInbound error") } @@ -71,18 +69,30 @@ func (ob *Observer) WatchInbound(ctx context.Context) error { } // ObserveInbound observes the Bitcoin chain for inbounds and post votes to zetacore. -func (ob *Observer) ObserveInbound(ctx context.Context, sampledLogger zerolog.Logger) error { +func (ob *Observer) ObserveInbound(ctx context.Context) error { chainID := ob.Chain().ChainId pageLimit := solanarpc.DefaultPageLimit - lastSig := solana.MustSignatureFromBase58(ob.LastTxScanned()) + + // scan from gateway 1st signature if last scanned tx is absent in the database + // the 1st gateway signature is typically the program initialization + if ob.LastTxScanned() == "" { + lastSig, err := solanarpc.GetFirstSignatureForAddress(ob.solClient, ob.gatewayID, pageLimit) + if err != nil { + return errors.Wrapf(err, "error GetFirstSignatureForAddress for chain %d address %s", chainID, ob.gatewayID) + } + ob.WithLastTxScanned(lastSig.String()) + } // get all signatures for the gateway address since last scanned signature + lastSig := solana.MustSignatureFromBase58(ob.LastTxScanned()) signatures, err := solanarpc.GetSignaturesForAddressUntil(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) + if len(signatures) > 0 { + ob.Logger().Inbound.Info().Msgf("ObserveInbound: got %d signatures for chain %d", len(signatures), chainID) + } // loop signature from oldest to latest to filter inbound events for i := len(signatures) - 1; i >= 0; i-- { @@ -112,7 +122,7 @@ func (ob *Observer) ObserveInbound(ctx context.Context, sampledLogger zerolog.Lo 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) + ob.Logger().Inbound.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 >= MaxSignaturesPerTicker { @@ -132,11 +142,13 @@ func (ob *Observer) FilterInboundEventAndVote(ctx context.Context, txResult *rpc } // build inbound vote message from event and post to zetacore - msg := ob.BuildInboundVoteMsgFromEvent(event) - if msg != nil { - _, err = ob.PostVoteInbound(ctx, msg, zetacore.PostVoteInboundExecutionGasLimit) - if err != nil { - return errors.Wrapf(err, "error PostVoteInbound") + if event != nil { + msg := ob.BuildInboundVoteMsgFromEvent(event) + if msg != nil { + _, err = ob.PostVoteInbound(ctx, msg, zetacore.PostVoteInboundExecutionGasLimit) + if err != nil { + return errors.Wrapf(err, "error PostVoteInbound") + } } } @@ -237,7 +249,7 @@ func (ob *Observer) ParseInboundAsDeposit( } // check if the instruction is a deposit or not - if inst.Discriminator == contract.DiscriminatorDeposit() { + if inst.Discriminator != contract.DiscriminatorDeposit() { return nil, nil } diff --git a/zetaclient/chains/solana/observer/inbound_test.go b/zetaclient/chains/solana/observer/inbound_test.go index 6667f63190..60f9427c4a 100644 --- a/zetaclient/chains/solana/observer/inbound_test.go +++ b/zetaclient/chains/solana/observer/inbound_test.go @@ -34,7 +34,8 @@ func Test_FilterInboundEventAndVote(t *testing.T) { chainParams := sample.ChainParams(chain.ChainId) chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" zetacoreClient := mocks.NewZetacoreClient(t).WithKeys(&keys.Keys{}) - ob, err := observer.NewObserver(chain, nil, *chainParams, zetacoreClient, nil, base.DefaultLogger(), nil) + dbpath := sample.CreateTempDir(t) + ob, err := observer.NewObserver(chain, nil, *chainParams, zetacoreClient, nil, dbpath, base.DefaultLogger(), nil) require.NoError(t, err) t.Run("should filter inbound event vote", func(t *testing.T) { @@ -53,7 +54,8 @@ func Test_FilterInboundEvent(t *testing.T) { // create observer chainParams := sample.ChainParams(chain.ChainId) chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" - ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, base.DefaultLogger(), nil) + dbpath := sample.CreateTempDir(t) + ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, dbpath, base.DefaultLogger(), nil) require.NoError(t, err) // expected result @@ -87,7 +89,8 @@ func Test_BuildInboundVoteMsgFromEvent(t *testing.T) { params := sample.ChainParams(chain.ChainId) params.GatewayAddress = sample.SolanaAddress(t) zetacoreClient := mocks.NewZetacoreClient(t).WithKeys(&keys.Keys{}) - ob, err := observer.NewObserver(chain, nil, *params, zetacoreClient, nil, base.DefaultLogger(), nil) + dbpath := sample.CreateTempDir(t) + ob, err := observer.NewObserver(chain, nil, *params, zetacoreClient, nil, dbpath, base.DefaultLogger(), nil) require.NoError(t, err) // create test compliance config @@ -97,7 +100,8 @@ func Test_BuildInboundVoteMsgFromEvent(t *testing.T) { 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")) + memo := sample.EthAddress().Bytes() + event := sample.InboundEvent(chain.ChainId, sender, sender, 1280, []byte(memo)) msg := ob.BuildInboundVoteMsgFromEvent(event) require.NotNil(t, msg) @@ -105,7 +109,7 @@ func Test_BuildInboundVoteMsgFromEvent(t *testing.T) { 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")) + event := sample.InboundEvent(chain.ChainId, sender, receiver, 1280, nil) // restrict sender cfg.ComplianceConfig.RestrictedAddresses = []string{sender} @@ -117,7 +121,8 @@ func Test_BuildInboundVoteMsgFromEvent(t *testing.T) { 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")) + memo := sample.EthAddress().Bytes() + event := sample.InboundEvent(chain.ChainId, sender, receiver, 1280, []byte(memo)) // restrict receiver cfg.ComplianceConfig.RestrictedAddresses = []string{receiver} @@ -149,7 +154,8 @@ func Test_ParseInboundAsDeposit(t *testing.T) { // create observer chainParams := sample.ChainParams(chain.ChainId) chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" - ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, base.DefaultLogger(), nil) + dbpath := sample.CreateTempDir(t) + ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, dbpath, base.DefaultLogger(), nil) require.NoError(t, err) // expected result diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index 81c16fe851..f4f8718c79 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -38,6 +38,7 @@ func NewObserver( chainParams observertypes.ChainParams, zetacoreClient interfaces.ZetacoreClient, tss interfaces.TSSSigner, + dbpath string, logger base.Logger, ts *metrics.TelemetryServer, ) (*Observer, error) { @@ -70,6 +71,12 @@ func NewObserver( return nil, err } + // load btc chain observer DB + err = ob.LoadDB(dbpath) + if err != nil { + return nil, err + } + return &ob, nil } diff --git a/zetaclient/chains/solana/rpc/rpc.go b/zetaclient/chains/solana/rpc/rpc.go index 69f251e7d0..3add097c7b 100644 --- a/zetaclient/chains/solana/rpc/rpc.go +++ b/zetaclient/chains/solana/rpc/rpc.go @@ -51,6 +51,11 @@ func GetFirstSignatureForAddress( lastSignature = fetchedSignatures[len(fetchedSignatures)-1].Signature } + // there is no signature for the given address + if lastSignature.IsZero() { + return lastSignature, errors.Errorf("no signatures found for address %s", address) + } + return lastSignature, nil } diff --git a/zetaclient/config/types.go b/zetaclient/config/types.go index e8325a7be0..4defd0df49 100644 --- a/zetaclient/config/types.go +++ b/zetaclient/config/types.go @@ -123,7 +123,11 @@ func (c Config) GetBTCConfig() (BTCConfig, bool) { // GetSolanaConfig returns the Solana config func (c Config) GetSolanaConfig() (SolanaConfig, bool) { - return c.SolanaConfig, c.SolanaConfig != (SolanaConfig{}) + // FIXME_SOLANA: config this + solConfig := SolanaConfig{ + Endpoint: "http://solana:8899", + } + return solConfig, solConfig != (SolanaConfig{}) } // String returns the string representation of the config From 76478d910cda767df724d2c4fd0d426911bb2416 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Mon, 15 Jul 2024 23:31:23 -0500 Subject: [PATCH 18/37] clean up unused files; reduce log prints --- cmd/solana/main.go | 373 ------------------ cmd/zetae2e/local/solana.go | 1 + contrib/localnet/docker-compose.yml | 22 +- e2e/e2etests/test_solana_deposit.go | 1 - e2e/e2etests/test_solana_initialize.go | 8 +- e2e/runner/setup_solana.go | 2 +- pkg/bg/bg.go | 1 + .../chains/solana/contract}/gateway.json | 0 .../chains/solana/contract/idl.go | 2 +- 9 files changed, 19 insertions(+), 391 deletions(-) delete mode 100644 cmd/solana/main.go rename {cmd/solana => zetaclient/chains/solana/contract}/gateway.json (100%) rename cmd/solana/types.go => zetaclient/chains/solana/contract/idl.go (98%) diff --git a/cmd/solana/main.go b/cmd/solana/main.go deleted file mode 100644 index fcf19d011b..0000000000 --- a/cmd/solana/main.go +++ /dev/null @@ -1,373 +0,0 @@ -package main - -import ( - "context" - _ "embed" - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "os" - - "github.com/davecgh/go-spew/spew" - "github.com/ethereum/go-ethereum/crypto" - "github.com/gagliardetto/solana-go" - "github.com/gagliardetto/solana-go/rpc" - "github.com/near/borsh-go" -) - -const ( - pythProgramDevnet = "gSbePebfvPy7tRqimPoVecS2UsBvYv46ynrzWocc92s" // this program has many many txs -) - -//go:embed gateway.json -var GatewayIDLJSON []byte - -func main() { - // devnet RPC - client := rpc.New("https://solana-devnet.g.allthatnode.com/archive/json_rpc/842c667c947e42e2a9995ac2ec75026d") - - limit := 10 - out, err := client.GetSignaturesForAddressWithOpts( - context.TODO(), - solana.MustPublicKeyFromBase58(pythProgramDevnet), - &rpc.GetSignaturesForAddressOpts{ - Limit: &limit, - Before: solana.MustSignatureFromBase58( - "5pLBywq74Nc6jYrWUqn9KjnYXHbQEY2UPkhWefZF5u4NYaUvEwz1Cirqaym9wDeHNAjiQwuLBfrdhXo8uFQA45jL", - ), - Until: solana.MustSignatureFromBase58( - "2coX9CckSmJWeHVqJNANeD7m4J7pctpSomxMon3h36droxCVB3JDbLyWQKMjnf85ntuFGxMLySykEMaRd5MDw35e", - ), - }, - ) - - if err != nil { - panic(err) - } - fmt.Printf("len(out) = %d\n", len(out)) - //spew.Dump(out) - for _, sig := range out { - fmt.Printf("%s %d %v\n", sig.Signature, sig.Slot, sig.Err == nil) - } - - { - bn, _ := client.GetFirstAvailableBlock(context.TODO()) - fmt.Printf("first available bn = %d\n", bn) - cutoffTimestamp, _ := client.GetBlockTime(context.TODO(), bn) - fmt.Printf("cutoffTimestamp = %s\n", cutoffTimestamp.Time()) - block, _ := client.GetBlock(context.TODO(), bn) - //spew.Dump(block) - fmt.Printf("block time %s, block height %d\n", block.BlockTime.Time(), *block.BlockHeight) - fmt.Printf("block #%d\n", len(block.Transactions)) - //first_tx := block.Signatures[0] - //spew.Dump(first_tx) - } - - { - // Parsing a Deposit Instruction - // devnet tx: deposit with memo - // https://solana.fm/tx/51746triQeve21zP1bcVEPvvsoXt94B57TU5exBvoy938bhGCfzBtsvKJbLpS1zRc2dmb3S3HBHnhTfbtKCBpmqg - const depositTx = "51746triQeve21zP1bcVEPvvsoXt94B57TU5exBvoy938bhGCfzBtsvKJbLpS1zRc2dmb3S3HBHnhTfbtKCBpmqg" - - tx, err := client.GetTransaction( - context.TODO(), - solana.MustSignatureFromBase58(depositTx), - &rpc.GetTransactionOpts{}) - if err != nil { - log.Fatalf("Error getting transaction: %v", err) - } - fmt.Printf("tx status: %v", tx.Meta.Err == nil) - //spew.Dump(tx) - type DepositInstructionParams struct { - Discriminator [8]byte - Amount uint64 - Memo []byte - } - //hexString := "f223c68952e1f2b6390500000000000014000000dead000000000000000042069420694206942069" - // Decode hex string to byte slice - //data, _ := hex.DecodeString(hexString) - transaction, _ := tx.Transaction.GetTransaction() - instruction := transaction.Message.Instructions[0] - data := instruction.Data - pk, _ := transaction.Message.Program(instruction.ProgramIDIndex) - fmt.Printf("Program ID: %s\n", pk) - var inst DepositInstructionParams - err = borsh.Deserialize(&inst, data) - if err != nil { - log.Fatalf("Error deserializing: %v", err) - } - fmt.Printf("Discriminator: %016x\n", inst.Discriminator) - fmt.Printf("U64 Parameter: %d\n", inst.Amount) - fmt.Printf("Vec (%d): %x\n", len(inst.Memo), inst.Memo) - } - - { - var idl IDL - err := json.Unmarshal(GatewayIDLJSON, &idl) - if err != nil { - panic(err) - } - //spew.Dump(idl) - } - - { - // explore failed transaction - //https://explorer.solana.com/tx/2LbBdmCkuVyQhHAvsZhZ1HLdH12jQbHY7brwH6xUBsZKKPuV8fomyz1Qh9CaCZSqo8FNefaR8ir7ngo7H3H2VfWv - txSig := solana.MustSignatureFromBase58( - "2LbBdmCkuVyQhHAvsZhZ1HLdH12jQbHY7brwH6xUBsZKKPuV8fomyz1Qh9CaCZSqo8FNefaR8ir7ngo7H3H2VfWv", - ) - client2 := rpc.New("https://solana-mainnet.g.allthatnode.com/archive/json_rpc/842c667c947e42e2a9995ac2ec75026d") - tx, err := client2.GetTransaction( - context.TODO(), - txSig, - &rpc.GetTransactionOpts{}) - if err != nil { - log.Fatalf("Error getting transaction: %v", err) - } - fmt.Printf("tx successful?: %v\n", tx.Meta.Err == nil) - spew.Dump(tx) - } - - pk := os.Getenv("SOLANA_WALLET_PK") - if pk == "" { - log.Fatal("SOLANA_WALLET_PK must be set (base58 encoded private key)") - } - - privkey, err := solana.PrivateKeyFromBase58(pk) - if err != nil { - log.Fatalf("Error getting private key: %v", err) - } - fmt.Println("account public key:", privkey.PublicKey()) - - ethPk := os.Getenv("ETH_WALLET_PK") - if ethPk == "" { - log.Fatal("ETH_WALLET_PK must be set (hex encoded private key)") - } - privkeyBytes, err := hex.DecodeString(ethPk) - if err != nil { - log.Fatalf("Error decoding hex private key: %v", err) - } - ethPrivkey, err := crypto.ToECDSA(privkeyBytes) - if err != nil { - log.Fatalf("Error converting to ECDSA: %v", err) - } - - { - // build & bcast a Depsosit tx - bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) - if err != nil { - log.Fatalf("Error getting balance: %v", err) - } - fmt.Println("account balance in SOL ", float64(bal.Value)/1e9) - - // building the transaction - recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) - if err != nil { - panic(err) - } - fmt.Println("recent blockhash:", recent.Value.Blockhash) - - programID := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") - seed := []byte("meta") - pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programID) - if err != nil { - panic(err) - } - fmt.Printf("computed pda: %s, bump %d\n", pdaComputed, bump) - - //pdaAccount := solana.MustPublicKeyFromBase58("4hA43LCh2Utef8EwCyWwYmWBoSeNq6RS2HdoLkWGm5z5") - var inst solana.GenericInstruction - accountSlice := []*solana.AccountMeta{} - accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) - accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) - accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) - accountSlice = append(accountSlice, solana.Meta(programID)) - inst.ProgID = programID - inst.AccountValues = accountSlice - - type DepositInstructionParams struct { - Discriminator [8]byte - Amount uint64 - Memo []byte - } - - inst.DataBytes, err = borsh.Serialize(DepositInstructionParams{ - Discriminator: [8]byte{0xf2, 0x23, 0xc6, 0x89, 0x52, 0xe1, 0xf2, 0xb6}, - Amount: 1338, - Memo: []byte("hello this is a good memo for you to enjoy"), - }) - //inst.DataBytes, err = hex.DecodeString("f223c68952e1f2b6390500000000000014000000dead000000000000000042069420694206942069") - if err != nil { - panic(err) - } - - tx, err := solana.NewTransaction( - []solana.Instruction{&inst}, - recent.Value.Blockhash, - solana.TransactionPayer(privkey.PublicKey()), - ) - if err != nil { - panic(err) - } - _, err = tx.Sign( - func(key solana.PublicKey) *solana.PrivateKey { - if privkey.PublicKey().Equals(key) { - return &privkey - } - return nil - }, - ) - if err != nil { - panic(fmt.Errorf("unable to sign transaction: %w", err)) - } - - spew.Dump(tx) - //wsClient, err := ws.Connect(context.Background(), rpc.DevNet_WS) - //if err != nil { - // panic(err) - //} - //sig, err := confirm.SendAndConfirmTransaction( - // context.TODO(), - // client, - // wsClient, - // tx, - //) - // tx: 33cVywTwufSy5NsNSnJS87wmkPwVAr9iiJqxAhhny9pazxWpiH6L24c6ruVnSjctcGasyt2ngnrtx3TqK6KU6x6j - - //sig, err := client.SendTransactionWithOpts( - // context.TODO(), - // tx, - // rpc.TransactionOpts{}, - //) - // broadcast success! see - // https://solana.fm/tx/43hXUywVouKeG5V98mjPysPWG9eKyKo6XDVHuoQs5YP1gJfa5z2UtU6hjJGgscrWzmYbhbqNW2hykvV6HYfBXATD - - //if err != nil { - // panic(err) - //} - //spew.Dump(sig) - } - - { - fmt.Printf("Build and broadcast a withdraw tx\n") - type WithdrawInstructionParams struct { - Discriminator [8]byte - Amount uint64 - Signature [64]byte - RecoveryID uint8 - MessageHash [32]byte - Nonce uint64 - } - // fetch PDA account - programID := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") - seed := []byte("meta") - pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programID) - if err != nil { - panic(err) - } - fmt.Printf("computed pda: %s, bump %d\n", pdaComputed, bump) - type PdaInfo struct { - Discriminator [8]byte - Nonce uint64 - TssAddress [20]byte - Authority [32]byte - } - pdaInfo, err := client.GetAccountInfo(context.TODO(), pdaComputed) - if err != nil { - panic(err) - } - - // deserialize PDA account - var pda PdaInfo - err = borsh.Deserialize(&pda, pdaInfo.Bytes()) - if err != nil { - panic(err) - } - - //spew.Dump(pda) - // building the transaction - recent, err := client.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) - if err != nil { - panic(err) - } - fmt.Println("recent blockhash:", recent.Value.Blockhash) - var inst solana.GenericInstruction - - pdaBalance, err := client.GetBalance(context.TODO(), pdaComputed, rpc.CommitmentFinalized) - if err != nil { - panic(err) - } - fmt.Printf("PDA balance in SOL %f\n", float64(pdaBalance.Value)/1e9) - var message []byte - - amount := uint64(2_337_000) - to := privkey.PublicKey() - bytes := make([]byte, 8) - nonce := pda.Nonce - binary.BigEndian.PutUint64(bytes, nonce) - message = append(message, bytes...) - binary.BigEndian.PutUint64(bytes, amount) - message = append(message, bytes...) - message = append(message, to.Bytes()...) - messageHash := crypto.Keccak256Hash(message) - // this sig will be 65 bytes; R || S || V, where V is 0 or 1 - signature, err := crypto.Sign(messageHash.Bytes(), ethPrivkey) - if err != nil { - panic(err) - } - var sig [64]byte - copy(sig[:], signature[:64]) - inst.DataBytes, err = borsh.Serialize(WithdrawInstructionParams{ - Discriminator: [8]byte{183, 18, 70, 156, 148, 109, 161, 34}, - Amount: amount, - Signature: sig, - RecoveryID: signature[64], - 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 - inst.AccountValues = accountSlice - tx, err := solana.NewTransaction( - []solana.Instruction{&inst}, - recent.Value.Blockhash, - solana.TransactionPayer(privkey.PublicKey()), - ) - if err != nil { - panic(err) - } - _, err = tx.Sign( - func(key solana.PublicKey) *solana.PrivateKey { - if privkey.PublicKey().Equals(key) { - return &privkey - } - return nil - }, - ) - if err != nil { - panic(fmt.Errorf("unable to sign transaction: %w", err)) - } - - spew.Dump(tx) - txsig, err := client.SendTransactionWithOpts( - context.TODO(), - tx, - rpc.TransactionOpts{}, - ) - //broadcast success! see - if err != nil { - panic(err) - } - spew.Dump(txsig) - } -} diff --git a/cmd/zetae2e/local/solana.go b/cmd/zetae2e/local/solana.go index 2de2be91e2..85405d6ecb 100644 --- a/cmd/zetae2e/local/solana.go +++ b/cmd/zetae2e/local/solana.go @@ -48,6 +48,7 @@ func solanaTestRoutine( return fmt.Errorf("solana tests failed: %v", err) } + // TODO: check Solana balance // if err := solanaRunner.CheckSolanaTSSBalance(); err != nil { // return err // } diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index f3c42b9a73..c182cf3e84 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -180,17 +180,6 @@ services: - ssh:/root/.ssh - preparams:/root/preparams - solana: - image: solana-local:latest - container_name: solana - hostname: solana - ports: - - "8899:8899" - networks: - mynetwork: - ipv4_address: 172.20.0.102 - entrypoint: [ "/usr/bin/start-solana.sh" ] - eth: image: ethereum/client-go:v1.10.26 container_name: eth @@ -234,6 +223,17 @@ services: -rpcauth=smoketest:63acf9b8dccecce914d85ff8c044b78b$$5892f9bbc84f4364e79f0970039f88bdd823f168d4acc76099ab97b14a766a99 -txindex=1 + solana: + image: solana-local:latest + container_name: solana + hostname: solana + ports: + - "8899:8899" + networks: + mynetwork: + ipv4_address: 172.20.0.103 + entrypoint: [ "/usr/bin/start-solana.sh" ] + orchestrator: image: orchestrator:latest tty: true diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go index 10033697c9..e489a4db35 100644 --- a/e2e/e2etests/test_solana_deposit.go +++ b/e2e/e2etests/test_solana_deposit.go @@ -42,7 +42,6 @@ func TestSolanaDeposit(r *runner.E2ERunner, _ []string) { // broadcast the transaction and wait for finalization sig, out := r.BroadcastTxSync(signedTx) - r.Logger.Print("deposit receiver address: %s", r.EVMAddress().String()) r.Logger.Print("deposit logs: %v", out.Meta.LogMessages) // wait for the cctx to be mined diff --git a/e2e/e2etests/test_solana_initialize.go b/e2e/e2etests/test_solana_initialize.go index c40dd0c79e..7df0465095 100644 --- a/e2e/e2etests/test_solana_initialize.go +++ b/e2e/e2etests/test_solana_initialize.go @@ -26,10 +26,9 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { // get deployer account balance privkey := solana.MustPrivateKeyFromBase58(r.Account.RawBase58PrivateKey.String()) - r.Logger.Print("deployer pubkey: %s", privkey.PublicKey().String()) bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) require.NoError(r, err) - r.Logger.Print("deployer balance in SOL %f:", float64(bal.Value)/1e9) + r.Logger.Print("deployer address: %s, balance: %f SOL", privkey.PublicKey().String(), float64(bal.Value)/1e9) // compute the gateway PDA address pdaComputed := r.ComputePdaAddress() @@ -44,7 +43,6 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { accountSlice = append(accountSlice, solana.Meta(programID)) inst.ProgID = programID inst.AccountValues = accountSlice - r.Logger.Print("TSS EthAddress: %s", r.TSSAddress) inst.DataBytes, err = borsh.Serialize(solanacontract.InitializeParams{ Discriminator: solanacontract.DiscriminatorInitialize(), @@ -69,5 +67,7 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { err = borsh.Deserialize(&pda, pdaInfo.Bytes()) require.NoError(r, err) tssAddress := ethcommon.BytesToAddress(pda.TssAddress[:]) - r.Logger.Print("PDA info Tss: %v", tssAddress) + + // check the TSS address + require.Equal(r, r.TSSAddress, tssAddress, "TSS address mismatch") } diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index 4ff78c989a..3dc3260397 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -10,7 +10,7 @@ func (r *E2ERunner) SetupSolanaAccount() { r.Logger.Print("⚙️ setting up Solana account") startTime := time.Now() defer func() { - r.Logger.Print("✅ Solana account setup in %s\n", time.Since(startTime)) + r.Logger.Print("✅ Solana account setup in %s", time.Since(startTime)) }() r.SetSolanaAddress() diff --git a/pkg/bg/bg.go b/pkg/bg/bg.go index 2c037c33be..43c77ae27c 100644 --- a/pkg/bg/bg.go +++ b/pkg/bg/bg.go @@ -63,6 +63,7 @@ func logError(err error, cfg config) { cfg.logger.Error().Err(err).Str("worker.name", name).Msgf("Background task failed") } +// printStack prints the stack trace when a panic occurs func printStack() { buf := make([]byte, 1024) for { diff --git a/cmd/solana/gateway.json b/zetaclient/chains/solana/contract/gateway.json similarity index 100% rename from cmd/solana/gateway.json rename to zetaclient/chains/solana/contract/gateway.json diff --git a/cmd/solana/types.go b/zetaclient/chains/solana/contract/idl.go similarity index 98% rename from cmd/solana/types.go rename to zetaclient/chains/solana/contract/idl.go index 1398be9962..4be13ee31e 100644 --- a/cmd/solana/types.go +++ b/zetaclient/chains/solana/contract/idl.go @@ -1,4 +1,4 @@ -package main +package contract type IDL struct { Address string `json:"address"` From 73c85f091e952b67acbecf9050a0a24ed1136663 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Mon, 15 Jul 2024 23:33:18 -0500 Subject: [PATCH 19/37] added entry to changelog --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 115a144a52..75b546b9b3 100644 --- a/changelog.md +++ b/changelog.md @@ -31,6 +31,7 @@ * [2366](https://github.com/zeta-chain/node/pull/2366) - add migration script for adding authorizations table * [2372](https://github.com/zeta-chain/node/pull/2372) - add queries for tss fund migration info * [2416](https://github.com/zeta-chain/node/pull/2416) - add Solana chain information +* [2465](https://github.com/zeta-chain/node/pull/2465) - add Solana inbound SOL token observation ### Refactor From 0b4d4ffbc9a08045d02fc1a9ac8688a51e40061d Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Tue, 16 Jul 2024 10:18:18 -0500 Subject: [PATCH 20/37] revert Dockerfile-localnet --- Dockerfile-localnet | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile-localnet b/Dockerfile-localnet index 9e3a98d0b4..efd2ac8b4f 100644 --- a/Dockerfile-localnet +++ b/Dockerfile-localnet @@ -17,8 +17,7 @@ COPY go.mod . COPY go.sum . RUN go mod download COPY version.sh . -#COPY --exclude=*.sh --exclude=*.md --exclude=*.yml . . -COPY . . +COPY --exclude=*.sh --exclude=*.md --exclude=*.yml . . RUN --mount=type=cache,target="/root/.cache/go-build" make install RUN --mount=type=cache,target="/root/.cache/go-build" make install-zetae2e From a4bccc424d9c12fa37f69d0fdafc7c806c8ccdad Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Tue, 16 Jul 2024 10:20:57 -0500 Subject: [PATCH 21/37] remove solana-test in Makefile because Solana e2e tests is ran as part of start-e2e-test --- Makefile | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Makefile b/Makefile index 2250d89ac5..0d5dd69435 100644 --- a/Makefile +++ b/Makefile @@ -258,11 +258,6 @@ start-stress-test: zetanode @echo "--> Starting stress test" cd contrib/localnet/ && $(DOCKER) compose --profile stress -f docker-compose.yml up -d -start-solana-test: zetanode solana - @echo "--> Starting solana test" - export E2E_ARGS="--test-solana" && \ - cd contrib/localnet/ && $(DOCKER) compose --profile solana -f docker-compose.yml up -d - ############################################################################### ### Upgrade Tests ### ############################################################################### From eb814a91d89bee5be48174081af7338f76cb197a Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Tue, 16 Jul 2024 22:55:58 -0500 Subject: [PATCH 22/37] polished e2e tests, solana config and chain parameters --- cmd/zetaclientd/start_utils.go | 2 + cmd/zetaclientd/utils.go | 30 +++++----- cmd/zetae2e/config/config.go | 2 +- cmd/zetae2e/config/contracts.go | 11 ++++ cmd/zetae2e/config/localnet.yml | 2 +- cmd/zetae2e/local/local.go | 1 + cmd/zetae2e/local/solana.go | 8 +-- contrib/localnet/solana/Dockerfile | 2 - e2e/config/config.go | 9 +-- e2e/e2etests/test_solana_deposit.go | 31 ++-------- e2e/e2etests/test_solana_initialize.go | 21 ++++--- e2e/runner/accounting.go | 46 +++++++++++++- e2e/runner/balances.go | 10 +++- e2e/runner/runner.go | 14 ++++- e2e/runner/setup_bitcoin.go | 2 +- e2e/runner/setup_solana.go | 2 +- e2e/runner/setup_zeta.go | 24 +++++++- e2e/runner/solana.go | 53 +++++++++++----- e2e/txserver/zeta_tx_server.go | 39 +----------- pkg/chains/chain.go | 2 +- .../contract/solana}/contract.go | 6 +- .../contract/solana}/gateway.json | 0 .../contract => pkg/contract/solana}/idl.go | 2 +- .../contract => pkg/contract/solana}/types.go | 2 +- x/observer/genesis.go | 3 + x/observer/types/chain_params.go | 20 +++++++ zetaclient/chains/base/observer.go | 7 +-- zetaclient/chains/base/observer_test.go | 10 ++-- .../chains/evm/observer/observer_test.go | 1 + zetaclient/chains/solana/observer/db.go | 4 +- zetaclient/chains/solana/observer/inbound.go | 10 ++-- .../chains/solana/observer/inbound_test.go | 8 ++- zetaclient/chains/solana/observer/observer.go | 4 +- zetaclient/config/config_chain.go | 8 +++ zetaclient/config/types.go | 10 ++-- zetaclient/context/app.go | 60 +++++++++++++++---- zetaclient/context/app_test.go | 7 +++ zetaclient/orchestrator/orchestrator_test.go | 1 + zetaclient/zetacore/client.go | 4 ++ 39 files changed, 313 insertions(+), 165 deletions(-) rename {zetaclient/chains/solana/contract => pkg/contract/solana}/contract.go (88%) rename {zetaclient/chains/solana/contract => pkg/contract/solana}/gateway.json (100%) rename {zetaclient/chains/solana/contract => pkg/contract/solana}/idl.go (98%) rename {zetaclient/chains/solana/contract => pkg/contract/solana}/types.go (98%) diff --git a/cmd/zetaclientd/start_utils.go b/cmd/zetaclientd/start_utils.go index 10f0b0beea..9692f32b29 100644 --- a/cmd/zetaclientd/start_utils.go +++ b/cmd/zetaclientd/start_utils.go @@ -84,8 +84,10 @@ func maskCfg(cfg config.Config) string { chain.Endpoint = endpointURL.Hostname() } + // mask endpoints maskedCfg.BitcoinConfig.RPCUsername = "" maskedCfg.BitcoinConfig.RPCPassword = "" + maskedCfg.SolanaConfig.Endpoint = "" return maskedCfg.String() } diff --git a/cmd/zetaclientd/utils.go b/cmd/zetaclientd/utils.go index 3e1a444889..a7799eadd4 100644 --- a/cmd/zetaclientd/utils.go +++ b/cmd/zetaclientd/utils.go @@ -10,7 +10,6 @@ import ( solrpc "github.com/gagliardetto/solana-go/rpc" "github.com/rs/zerolog" - 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" @@ -171,7 +170,7 @@ func CreateChainObserverMap( } // BTC observer - _, chainParams, found := appContext.GetBTCChainParams() + _, btcChainParams, found := appContext.GetBTCChainParams() if !found { return nil, fmt.Errorf("bitcoin chains params not found") } @@ -187,7 +186,7 @@ func CreateChainObserverMap( observer, err := btcobserver.NewObserver( btcChain, btcClient, - *chainParams, + *btcChainParams, zetacoreClient, tss, dbpath, @@ -202,28 +201,27 @@ func CreateChainObserverMap( } } - // FIXME_SOLANA: config chain params - solChain, solConfig, enabled := appContext.GetSolanaChainAndConfig() - solChainParams := observertypes.ChainParams{ - GatewayAddress: "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", - IsSupported: true, - ChainId: solChain.ChainId, - InboundTicker: 10, + // Solana chain params + _, solChainParams, found := appContext.GetSolanaChainParams() + if !found { + logger.Std.Error().Msg("solana chain params not found") + return observerMap, nil } - // create Solana chain observer if enabled + // create Solana chain observer + solChain, solConfig, enabled := appContext.GetSolanaChainAndConfig() if enabled { rpcClient := solrpc.New(solConfig.Endpoint) if rpcClient == nil { // should never happen - return nil, fmt.Errorf("solana create Solana client error") + logger.Std.Error().Msg("solana create Solana client error") + return observerMap, nil } - // create Solana chain observer - co, err := solanaobserver.NewObserver( + observer, err := solanaobserver.NewObserver( solChain, rpcClient, - solChainParams, + *solChainParams, zetacoreClient, tss, dbpath, @@ -233,7 +231,7 @@ func CreateChainObserverMap( if err != nil { logger.Std.Error().Err(err).Msg("NewObserver error for solana chain") } else { - observerMap[solChainParams.ChainId] = co + observerMap[solChainParams.ChainId] = observer } } diff --git a/cmd/zetae2e/config/config.go b/cmd/zetae2e/config/config.go index c3584dc720..deebb935f7 100644 --- a/cmd/zetae2e/config/config.go +++ b/cmd/zetae2e/config/config.go @@ -18,7 +18,6 @@ func RunnerFromConfig( logger *runner.Logger, opts ...runner.E2ERunnerOption, ) (*runner.E2ERunner, error) { - //spew.Dump("RunnerFromConfig conf struct", conf) // initialize clients btcRPCClient, solanaClient, @@ -91,6 +90,7 @@ func ExportContractsFromRunner(r *runner.E2ERunner, conf config.Config) config.C conf.Contracts.ZEVM.ETHZRC20Addr = config.DoubleQuotedString(r.ETHZRC20Addr.Hex()) conf.Contracts.ZEVM.ERC20ZRC20Addr = config.DoubleQuotedString(r.ERC20ZRC20Addr.Hex()) conf.Contracts.ZEVM.BTCZRC20Addr = config.DoubleQuotedString(r.BTCZRC20Addr.Hex()) + conf.Contracts.ZEVM.SOLZRC20Addr = config.DoubleQuotedString(r.SOLZRC20Addr.Hex()) conf.Contracts.ZEVM.UniswapFactoryAddr = config.DoubleQuotedString(r.UniswapV2FactoryAddr.Hex()) conf.Contracts.ZEVM.UniswapRouterAddr = config.DoubleQuotedString(r.UniswapV2RouterAddr.Hex()) conf.Contracts.ZEVM.ConnectorZEVMAddr = config.DoubleQuotedString(r.ConnectorZEVMAddr.Hex()) diff --git a/cmd/zetae2e/config/contracts.go b/cmd/zetae2e/config/contracts.go index 6ca59f0414..aa1e541957 100644 --- a/cmd/zetae2e/config/contracts.go +++ b/cmd/zetae2e/config/contracts.go @@ -120,6 +120,17 @@ func setContractsFromConfig(r *runner.E2ERunner, conf config.Config) error { } } + if c := conf.Contracts.ZEVM.SOLZRC20Addr; c != "" { + r.SOLZRC20Addr, err = c.AsEVMAddress() + if err != nil { + return fmt.Errorf("invalid SOLZRC20Addr: %w", err) + } + r.SOLZRC20, err = zrc20.NewZRC20(r.SOLZRC20Addr, r.ZEVMClient) + if err != nil { + return err + } + } + if c := conf.Contracts.ZEVM.UniswapFactoryAddr; c != "" { r.UniswapV2FactoryAddr, err = c.AsEVMAddress() if err != nil { diff --git a/cmd/zetae2e/config/localnet.yml b/cmd/zetae2e/config/localnet.yml index 1c082d0f4e..ed3338a540 100644 --- a/cmd/zetae2e/config/localnet.yml +++ b/cmd/zetae2e/config/localnet.yml @@ -24,7 +24,7 @@ additional_accounts: bech32_address: "zeta1zqlajgj0qr8rqylf2c572t0ux8vqt45d4zngpm" evm_address: "0x103FD9224F00ce3013e95629e52DFc31D805D68d" private_key: "dd53f191113d18e57bd4a5494a64a020ba7919c815d0a6d34a42ebb2839e9d95" - base58_private_key: "4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C" + solana_private_key: "4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C" user_ether: bech32_address: "zeta134rakuus43xn63yucgxhn88ywj8ewcv6ezn2ga" evm_address: "0x8D47Db7390AC4D3D449Cc20D799ce4748F97619A" diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index a61d97fc28..7060c28de4 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -171,6 +171,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { deployerRunner.SetupEVM(contractsDeployed, true) deployerRunner.SetZEVMContracts() + deployerRunner.SetSolanaContracts() noError(deployerRunner.FundEmissionsPool()) deployerRunner.MintERC20OnEvm(10000) diff --git a/cmd/zetae2e/local/solana.go b/cmd/zetae2e/local/solana.go index 85405d6ecb..ef8ffce1ea 100644 --- a/cmd/zetae2e/local/solana.go +++ b/cmd/zetae2e/local/solana.go @@ -48,10 +48,10 @@ func solanaTestRoutine( return fmt.Errorf("solana tests failed: %v", err) } - // TODO: check Solana balance - // if err := solanaRunner.CheckSolanaTSSBalance(); err != nil { - // return err - // } + // check gateway SOL balance against ZRC20 total supply + if err := solanaRunner.CheckSolanaTSSBalance(); err != nil { + return err + } solanaRunner.Logger.Print("🍾 solana tests completed in %s", time.Since(startTime).String()) diff --git a/contrib/localnet/solana/Dockerfile b/contrib/localnet/solana/Dockerfile index fec6d8c75e..5806947cca 100644 --- a/contrib/localnet/solana/Dockerfile +++ b/contrib/localnet/solana/Dockerfile @@ -6,7 +6,5 @@ RUN chmod +x /usr/bin/start-solana.sh COPY ./gateway.so . COPY ./gateway-keypair.json . - - ENTRYPOINT [ "bash" ] CMD [ "/usr/bin/start-solana.sh" ] \ No newline at end of file diff --git a/e2e/config/config.go b/e2e/config/config.go index c2769c00e9..8dd370730f 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -51,10 +51,10 @@ type Config struct { // Account contains configuration for an account type Account struct { - RawBech32Address DoubleQuotedString `yaml:"bech32_address"` - RawEVMAddress DoubleQuotedString `yaml:"evm_address"` - RawPrivateKey DoubleQuotedString `yaml:"private_key"` - RawBase58PrivateKey DoubleQuotedString `yaml:"base58_private_key"` + RawBech32Address DoubleQuotedString `yaml:"bech32_address"` + RawEVMAddress DoubleQuotedString `yaml:"evm_address"` + RawPrivateKey DoubleQuotedString `yaml:"private_key"` + SolanaPrivateKey DoubleQuotedString `yaml:"solana_private_key"` } // AdditionalAccounts are extra accounts required to run specific tests @@ -122,6 +122,7 @@ type ZEVM struct { ETHZRC20Addr DoubleQuotedString `yaml:"eth_zrc20"` ERC20ZRC20Addr DoubleQuotedString `yaml:"erc20_zrc20"` BTCZRC20Addr DoubleQuotedString `yaml:"btc_zrc20"` + SOLZRC20Addr DoubleQuotedString `yaml:"sol_zrc20"` UniswapFactoryAddr DoubleQuotedString `yaml:"uniswap_factory"` UniswapRouterAddr DoubleQuotedString `yaml:"uniswap_router"` ConnectorZEVMAddr DoubleQuotedString `yaml:"connector_zevm"` diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go index e489a4db35..581a420776 100644 --- a/e2e/e2etests/test_solana_deposit.go +++ b/e2e/e2etests/test_solana_deposit.go @@ -2,47 +2,26 @@ package e2etests import ( "github.com/gagliardetto/solana-go" - "github.com/near/borsh-go" - "github.com/stretchr/testify/require" "github.com/zeta-chain/zetacore/e2e/runner" "github.com/zeta-chain/zetacore/e2e/utils" crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" - solanacontract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" ) func TestSolanaDeposit(r *runner.E2ERunner, _ []string) { // load deployer private key - privkey := solana.MustPrivateKeyFromBase58(r.Account.RawBase58PrivateKey.String()) - - // compute the gateway PDA address - pdaComputed := r.ComputePdaAddress() - programID := r.GatewayProgramID() + privkey := solana.MustPrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) // create 'deposit' instruction - var inst solana.GenericInstruction - accountSlice := []*solana.AccountMeta{} - accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) - accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) - accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) - accountSlice = append(accountSlice, solana.Meta(programID)) - inst.ProgID = programID - inst.AccountValues = accountSlice - - var err error - inst.DataBytes, err = borsh.Serialize(solanacontract.DepositInstructionParams{ - Discriminator: solanacontract.DiscriminatorDeposit(), - Amount: 13370000, - Memo: r.EVMAddress().Bytes(), - }) - require.NoError(r, err) + amount := uint64(13370000) + instruction := r.CreateDepositInstruction(privkey.PublicKey(), r.EVMAddress(), amount) // create and sign the transaction - signedTx := r.CreateSignedTransaction([]solana.Instruction{&inst}, privkey) + signedTx := r.CreateSignedTransaction([]solana.Instruction{instruction}, privkey) // broadcast the transaction and wait for finalization sig, out := r.BroadcastTxSync(signedTx) - r.Logger.Print("deposit logs: %v", out.Meta.LogMessages) + r.Logger.Info("deposit logs: %v", out.Meta.LogMessages) // wait for the cctx to be mined cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, sig.String(), r.CctxClient, r.Logger, r.CctxTimeout) diff --git a/e2e/e2etests/test_solana_initialize.go b/e2e/e2etests/test_solana_initialize.go index 7df0465095..e40968518d 100644 --- a/e2e/e2etests/test_solana_initialize.go +++ b/e2e/e2etests/test_solana_initialize.go @@ -11,7 +11,7 @@ import ( "github.com/zeta-chain/zetacore/e2e/runner" "github.com/zeta-chain/zetacore/pkg/chains" - solanacontract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" ) func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { @@ -22,17 +22,17 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { client := r.SolanaClient res, err := client.GetVersion(context.Background()) require.NoError(r, err) - r.Logger.Print("solana version: %+v", res) + r.Logger.Info("solana version: %+v", res) // get deployer account balance - privkey := solana.MustPrivateKeyFromBase58(r.Account.RawBase58PrivateKey.String()) - bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) + privkey := solana.MustPrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + bal, err := client.GetBalance(r.Ctx, privkey.PublicKey(), rpc.CommitmentFinalized) require.NoError(r, err) - r.Logger.Print("deployer address: %s, balance: %f SOL", privkey.PublicKey().String(), float64(bal.Value)/1e9) + r.Logger.Info("deployer address: %s, balance: %f SOL", privkey.PublicKey().String(), float64(bal.Value)/1e9) // compute the gateway PDA address pdaComputed := r.ComputePdaAddress() - programID := r.GatewayProgramID() + programID := r.GatewayProgram // create 'initialize' instruction var inst solana.GenericInstruction @@ -56,10 +56,10 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { // broadcast the transaction and wait for finalization _, out := r.BroadcastTxSync(signedTx) - r.Logger.Print("initialize logs: %v", out.Meta.LogMessages) + r.Logger.Info("initialize logs: %v", out.Meta.LogMessages) // retrieve the PDA account info - pdaInfo, err := client.GetAccountInfo(context.TODO(), pdaComputed) + pdaInfo, err := client.GetAccountInfo(r.Ctx, pdaComputed) require.NoError(r, err) // deserialize the PDA info @@ -70,4 +70,9 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { // check the TSS address require.Equal(r, r.TSSAddress, tssAddress, "TSS address mismatch") + + // show the PDA balance + balance, err := client.GetBalance(r.Ctx, pdaComputed, rpc.CommitmentConfirmed) + require.NoError(r, err) + r.Logger.Info("initial PDA balance: %d lamports", balance.Value) } diff --git a/e2e/runner/accounting.go b/e2e/runner/accounting.go index d5a7e3a9e2..4da570c3a2 100644 --- a/e2e/runner/accounting.go +++ b/e2e/runner/accounting.go @@ -8,6 +8,16 @@ import ( "net/http" "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/require" +) + +const ( + // ZRC20InitialSupply is the initial supply of the ZRC20 token + ZRC20SOLInitialSupply = 100000000 + + // SolanaPDAInitialBalance is the initial balance (in lamports) of the gateway PDA account + SolanaPDAInitialBalance = 1447680 ) type Amount struct { @@ -75,7 +85,7 @@ func (r *E2ERunner) CheckBtcTSSBalance() error { ) } // #nosec G115 test - always in range - r.Logger.Info( + r.Logger.Print( "BTC: Balance (%d) >= ZRC20 TotalSupply (%d)", int64(btcBalance*1e8), zrc20Supply.Int64()-10000000, @@ -84,6 +94,40 @@ func (r *E2ERunner) CheckBtcTSSBalance() error { return nil } +// CheckSolanaTSSBalance compares the gateway PDA balance with the total supply of the SOL ZRC20 on ZetaChain +func (r *E2ERunner) CheckSolanaTSSBalance() error { + zrc20Supply, err := r.SOLZRC20.TotalSupply(&bind.CallOpts{}) + if err != nil { + return err + } + + // get PDA received amount + pda := r.ComputePdaAddress() + balance, err := r.SolanaClient.GetBalance(r.Ctx, pda, rpc.CommitmentConfirmed) + require.NoError(r, err) + pdaReceivedAmount := balance.Value - SolanaPDAInitialBalance + + // the SOL balance in gateway PDA must not be less than the total supply on ZetaChain + // the amount minted to initialize the pool is subtracted from the total supply + // #nosec G115 test - always in range + if pdaReceivedAmount < (zrc20Supply.Uint64() - ZRC20SOLInitialSupply) { + // #nosec G115 test - always in range + return fmt.Errorf( + "SOL: Gateway PDA Received (%d) < ZRC20 TotalSupply (%d)", + pdaReceivedAmount, + zrc20Supply.Uint64()-ZRC20SOLInitialSupply, + ) + } + // #nosec G115 test - always in range + r.Logger.Info( + "SOL: Gateway PDA Received (%d) >= ZRC20 TotalSupply (%d)", + pdaReceivedAmount, + zrc20Supply.Int64()-ZRC20SOLInitialSupply, + ) + + return nil +} + func (r *E2ERunner) checkERC20TSSBalance() error { erc20Balance, err := r.ERC20.BalanceOf(&bind.CallOpts{}, r.ERC20CustodyAddr) if err != nil { diff --git a/e2e/runner/balances.go b/e2e/runner/balances.go index d1e19d4c61..f7ab0938c1 100644 --- a/e2e/runner/balances.go +++ b/e2e/runner/balances.go @@ -17,6 +17,7 @@ type AccountBalances struct { ZetaWZETA *big.Int ZetaERC20 *big.Int ZetaBTC *big.Int + ZetaSOL *big.Int EvmETH *big.Int EvmZETA *big.Int EvmERC20 *big.Int @@ -53,6 +54,10 @@ func (r *E2ERunner) GetAccountBalances(skipBTC bool) (AccountBalances, error) { if err != nil { return AccountBalances{}, err } + zetaSol, err := r.SOLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + if err != nil { + return AccountBalances{}, err + } // evm evmEth, err := r.EVMClient.BalanceAt(r.Ctx, r.EVMAddress(), nil) @@ -82,6 +87,7 @@ func (r *E2ERunner) GetAccountBalances(skipBTC bool) (AccountBalances, error) { ZetaWZETA: zetaWZeta, ZetaERC20: zetaErc20, ZetaBTC: zetaBtc, + ZetaSOL: zetaSol, EvmETH: evmEth, EvmZETA: evmZeta, EvmERC20: evmErc20, @@ -149,7 +155,9 @@ func (r *E2ERunner) PrintAccountBalances(balances AccountBalances) { r.Logger.Print("Bitcoin:") r.Logger.Print("* BTC balance: %s", balances.BtcBTC) - return + // solana + r.Logger.Print("Solana:") + r.Logger.Print("* SOL balance: %s", balances.ZetaSOL.String()) } // PrintTotalDiff shows the difference in the account balances of the accounts used in the e2e test from two balances structs diff --git a/e2e/runner/runner.go b/e2e/runner/runner.go index 3a57e382f7..0dcffcb597 100644 --- a/e2e/runner/runner.go +++ b/e2e/runner/runner.go @@ -102,6 +102,8 @@ type E2ERunner struct { ETHZRC20 *zrc20.ZRC20 BTCZRC20Addr ethcommon.Address BTCZRC20 *zrc20.ZRC20 + SOLZRC20Addr ethcommon.Address + SOLZRC20 *zrc20.ZRC20 UniswapV2FactoryAddr ethcommon.Address UniswapV2Factory *uniswapv2factory.UniswapV2Factory UniswapV2RouterAddr ethcommon.Address @@ -197,6 +199,7 @@ func (r *E2ERunner) CopyAddressesFrom(other *E2ERunner) (err error) { r.ERC20ZRC20Addr = other.ERC20ZRC20Addr r.ETHZRC20Addr = other.ETHZRC20Addr r.BTCZRC20Addr = other.BTCZRC20Addr + r.SOLZRC20Addr = other.SOLZRC20Addr r.UniswapV2FactoryAddr = other.UniswapV2FactoryAddr r.UniswapV2RouterAddr = other.UniswapV2RouterAddr r.ConnectorZEVMAddr = other.ConnectorZEVMAddr @@ -238,6 +241,10 @@ func (r *E2ERunner) CopyAddressesFrom(other *E2ERunner) (err error) { if err != nil { return err } + r.SOLZRC20, err = zrc20.NewZRC20(r.SOLZRC20Addr, r.ZEVMClient) + if err != nil { + return err + } r.UniswapV2Factory, err = uniswapv2factory.NewUniswapV2Factory(r.UniswapV2FactoryAddr, r.ZEVMClient) if err != nil { return err @@ -292,6 +299,7 @@ func (r *E2ERunner) PrintContractAddresses() { r.Logger.Print("ETHZRC20: %s", r.ETHZRC20Addr.Hex()) r.Logger.Print("ERC20ZRC20: %s", r.ERC20ZRC20Addr.Hex()) r.Logger.Print("BTCZRC20: %s", r.BTCZRC20Addr.Hex()) + r.Logger.Print("SOLZRC20: %s", r.SOLZRC20Addr.Hex()) r.Logger.Print("UniswapFactory: %s", r.UniswapV2FactoryAddr.Hex()) r.Logger.Print("UniswapRouter: %s", r.UniswapV2RouterAddr.Hex()) r.Logger.Print("ConnectorZEVM: %s", r.ConnectorZEVMAddr.Hex()) @@ -299,15 +307,15 @@ func (r *E2ERunner) PrintContractAddresses() { r.Logger.Print("ZEVMSwapApp: %s", r.ZEVMSwapAppAddr.Hex()) r.Logger.Print("ContextApp: %s", r.ContextAppAddr.Hex()) - r.Logger.Print("TestDappZEVM: %s", r.ZevmTestDAppAddr.Hex()) + r.Logger.Print("TestDappZEVM: %s", r.ZevmTestDAppAddr.Hex()) // evm contracts r.Logger.Print(" --- 📜EVM contracts ---") r.Logger.Print("ZetaEth: %s", r.ZetaEthAddr.Hex()) r.Logger.Print("ConnectorEth: %s", r.ConnectorEthAddr.Hex()) r.Logger.Print("ERC20Custody: %s", r.ERC20CustodyAddr.Hex()) - r.Logger.Print("ERC20: %s", r.ERC20Addr.Hex()) - r.Logger.Print("TestDappEVM: %s", r.EvmTestDAppAddr.Hex()) + r.Logger.Print("ERC20: %s", r.ERC20Addr.Hex()) + r.Logger.Print("TestDappEVM: %s", r.EvmTestDAppAddr.Hex()) } // Errorf logs an error message. Mimics the behavior of testing.T.Errorf diff --git a/e2e/runner/setup_bitcoin.go b/e2e/runner/setup_bitcoin.go index 15b6d4a11d..d97ed90067 100644 --- a/e2e/runner/setup_bitcoin.go +++ b/e2e/runner/setup_bitcoin.go @@ -14,7 +14,7 @@ func (r *E2ERunner) SetupBitcoinAccount(initNetwork bool) { r.Logger.Print("⚙️ setting up Bitcoin account") startTime := time.Now() defer func() { - r.Logger.Print("✅ Bitcoin account setup in %s\n", time.Since(startTime)) + r.Logger.Print("✅ Bitcoin account setup in %s", time.Since(startTime)) }() _, err := r.BtcRPCClient.CreateWallet(r.Name, rpcclient.WithCreateWalletBlank()) diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index 3dc3260397..da6bd099a9 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -18,7 +18,7 @@ func (r *E2ERunner) SetupSolanaAccount() { // SetSolanaAddress imports the deployer's private key func (r *E2ERunner) SetSolanaAddress() { - privateKey := solana.MustPrivateKeyFromBase58(r.Account.RawBase58PrivateKey.String()) + privateKey := solana.MustPrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) r.SolanaDeployerAddress = privateKey.PublicKey() r.Logger.Info("SolanaDeployerAddress: %s", r.SolanaDeployerAddress) diff --git a/e2e/runner/setup_zeta.go b/e2e/runner/setup_zeta.go index 0100da4d8a..d21cf7d564 100644 --- a/e2e/runner/setup_zeta.go +++ b/e2e/runner/setup_zeta.go @@ -22,6 +22,7 @@ import ( "github.com/zeta-chain/zetacore/e2e/txserver" e2eutils "github.com/zeta-chain/zetacore/e2e/utils" "github.com/zeta-chain/zetacore/pkg/chains" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" fungibletypes "github.com/zeta-chain/zetacore/x/fungible/types" observertypes "github.com/zeta-chain/zetacore/x/observer/types" ) @@ -71,8 +72,7 @@ func (r *E2ERunner) SetSolanaContracts() { r.Logger.Print("⚙️ setting up Solana contracts") // set Solana contracts - // TODO: remove this hardcoded stuff for localnet - r.GatewayProgram = solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") + r.GatewayProgram = solana.MustPublicKeyFromBase58(solanacontract.SolanaGatewayProgramID) } // SetZEVMContracts set contracts for the ZEVM @@ -135,6 +135,7 @@ func (r *E2ERunner) SetZEVMContracts() { // set ZRC20 contracts r.SetupETHZRC20() r.SetupBTCZRC20() + r.SetupSOLZRC20() // deploy TestDApp contract on zEVM appAddr, txApp, _, err := testdapp.DeployTestDApp( @@ -215,6 +216,25 @@ func (r *E2ERunner) SetupBTCZRC20() { r.BTCZRC20 = BTCZRC20 } +// SetupSOLZRC20 sets up the SOL ZRC20 in the runner from the values queried from the chain +func (r *E2ERunner) SetupSOLZRC20() { + // set SOLZRC20 address by chain ID + SOLZRC20Addr, err := r.SystemContract.GasCoinZRC20ByChainId( + &bind.CallOpts{}, + big.NewInt(chains.SolanaLocalnet.ChainId), + ) + require.NoError(r, err) + + // set SOLZRC20 address + r.SOLZRC20Addr = SOLZRC20Addr + r.Logger.Info("SOLZRC20Addr: %s", SOLZRC20Addr.Hex()) + + // set SOLZRC20 contract + SOLZRC20, err := zrc20.NewZRC20(SOLZRC20Addr, r.ZEVMClient) + require.NoError(r, err) + r.SOLZRC20 = SOLZRC20 +} + // EnableHeaderVerification enables the header verification for the given chain IDs func (r *E2ERunner) EnableHeaderVerification(chainIDList []int64) error { r.Logger.Print("⚙️ enabling verification flags for block headers") diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go index 42ee4ea260..3338eaa6f4 100644 --- a/e2e/runner/solana.go +++ b/e2e/runner/solana.go @@ -1,25 +1,21 @@ package runner import ( - "context" "time" + ethcommon "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" "github.com/stretchr/testify/require" - solanacontract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" ) -// GatewayProgramID is the program ID for the gateway program -func (r *E2ERunner) GatewayProgramID() solana.PublicKey { - return solana.MustPublicKeyFromBase58(solanacontract.GatewayProgramID) -} - // ComputePdaAddress computes the PDA address for the gateway program func (r *E2ERunner) ComputePdaAddress() solana.PublicKey { seed := []byte(solanacontract.PDASeed) - GatewayProgramID := solana.MustPublicKeyFromBase58(solanacontract.GatewayProgramID) + GatewayProgramID := solana.MustPublicKeyFromBase58(solanacontract.SolanaGatewayProgramID) pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, GatewayProgramID) require.NoError(r, err) @@ -28,13 +24,44 @@ func (r *E2ERunner) ComputePdaAddress() solana.PublicKey { return pdaComputed } +// CreateDepositInstruction creates a 'deposit' instruction +func (r *E2ERunner) CreateDepositInstruction( + signer solana.PublicKey, + receiver ethcommon.Address, + amount uint64, +) solana.Instruction { + // compute the gateway PDA address + pdaComputed := r.ComputePdaAddress() + programID := r.GatewayProgram + + // create 'deposit' instruction + inst := &solana.GenericInstruction{} + accountSlice := []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(signer).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + accountSlice = append(accountSlice, solana.Meta(programID)) + inst.ProgID = programID + inst.AccountValues = accountSlice + + var err error + inst.DataBytes, err = borsh.Serialize(solanacontract.DepositInstructionParams{ + Discriminator: solanacontract.DiscriminatorDeposit(), + Amount: amount, + Memo: receiver.Bytes(), + }) + require.NoError(r, err) + + return inst +} + // CreateSignedTransaction creates a signed transaction from instructions func (r *E2ERunner) CreateSignedTransaction( instructions []solana.Instruction, privateKey solana.PrivateKey, ) *solana.Transaction { // get a recent blockhash - recent, err := r.SolanaClient.GetRecentBlockhash(context.TODO(), rpc.CommitmentFinalized) + recent, err := r.SolanaClient.GetRecentBlockhash(r.Ctx, rpc.CommitmentFinalized) require.NoError(r, err) // create the initialize transaction @@ -62,11 +89,7 @@ func (r *E2ERunner) CreateSignedTransaction( // BroadcastTxSync broadcasts a transaction and waits for it to be finalized func (r *E2ERunner) BroadcastTxSync(tx *solana.Transaction) (solana.Signature, *rpc.GetTransactionResult) { // broadcast the transaction - sig, err := r.SolanaClient.SendTransactionWithOpts( - context.TODO(), - tx, - rpc.TransactionOpts{}, - ) + sig, err := r.SolanaClient.SendTransactionWithOpts(r.Ctx, tx, rpc.TransactionOpts{}) require.NoError(r, err) r.Logger.Info("broadcast success! tx sig %s; waiting for confirmation...", sig) @@ -74,7 +97,7 @@ func (r *E2ERunner) BroadcastTxSync(tx *solana.Transaction) (solana.Signature, * var out *rpc.GetTransactionResult for { time.Sleep(1 * time.Second) - out, err = r.SolanaClient.GetTransaction(context.TODO(), sig, &rpc.GetTransactionOpts{}) + out, err = r.SolanaClient.GetTransaction(r.Ctx, sig, &rpc.GetTransactionOpts{}) if err == nil { break } diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index b1a8313174..5e111d8440 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -237,17 +237,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", zts.blockTimeout) case <-time.After(time.Millisecond * 100): resTx, err := zts.clientCtx.Client.Tx(context.TODO(), hash, false) - if err == nil { - txr, err := mkTxResult(zts.clientCtx, resTx) - return txr, err + return mkTxResult(zts.clientCtx, resTx) } } } @@ -388,35 +382,6 @@ func (zts ZetaTxServer) DeploySystemContractsAndZRC20( return "", "", "", "", "", fmt.Errorf("failed to deploy btc zrc20: %s", err.Error()) } - // FIXME: config this - chainParams := observertypes.ChainParams{ - ChainId: chains.SolanaLocalnet.ChainId, - IsSupported: true, - GatewayAddress: "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", - BallotThreshold: sdktypes.MustNewDecFromStr("0.66"), - ConfirmationCount: 32, - GasPriceTicker: 100, - InboundTicker: 5, - OutboundTicker: 5, - OutboundScheduleInterval: 10, - OutboundScheduleLookahead: 10, - MinObserverDelegation: sdktypes.MustNewDecFromStr("1"), - } - msg := observertypes.NewMsgUpdateChainParams( - addr.String(), - &chainParams, - ) - err = msg.ValidateBasic() - if err != nil { - return "", "", "", "", "", fmt.Errorf("failed to validate chain params: %s", err.Error()) - } - _, err = zts.BroadcastTx(account, msg) - if err != nil { - fmt.Printf("failed to update chain params: %s\n", err.Error()) - return "", "", "", "", "", fmt.Errorf("failed to update chain params (FungibleAdminName): %s", err.Error()) - } - //require.NoError(r, err) - // deploy sol zrc20 _, err = zts.BroadcastTx(account, fungibletypes.NewMsgDeployFungibleCoinZRC20( addr.String(), diff --git a/pkg/chains/chain.go b/pkg/chains/chain.go index 472713c12e..6e2ea1d500 100644 --- a/pkg/chains/chain.go +++ b/pkg/chains/chain.go @@ -116,7 +116,7 @@ func IsBitcoinChain(chainID int64, additionalChains []Chain) bool { // IsSolanaChain returns true if the chain is a Solana chain func IsSolanaChain(chainID int64, additionalChains []Chain) bool { - return ChainIDInChainList(chainID, ChainListByConsensus(Consensus_solana_consensus, additionalChains)) + return ChainIDInChainList(chainID, ChainListByNetwork(Network_solana, additionalChains)) } // IsEthereumChain returns true if the chain is an Ethereum chain diff --git a/zetaclient/chains/solana/contract/contract.go b/pkg/contract/solana/contract.go similarity index 88% rename from zetaclient/chains/solana/contract/contract.go rename to pkg/contract/solana/contract.go index a8e61a369f..2e2acd7ad6 100644 --- a/zetaclient/chains/solana/contract/contract.go +++ b/pkg/contract/solana/contract.go @@ -1,8 +1,8 @@ -package contract +package solana const ( - // GatewayProgramID is the program ID of the Solana gateway program - GatewayProgramID = "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" + // SolanaGatewayProgramID is the program ID of the Solana gateway program + SolanaGatewayProgramID = "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" // PDASeed is the seed for the Solana gateway program derived address PDASeed = "meta" diff --git a/zetaclient/chains/solana/contract/gateway.json b/pkg/contract/solana/gateway.json similarity index 100% rename from zetaclient/chains/solana/contract/gateway.json rename to pkg/contract/solana/gateway.json diff --git a/zetaclient/chains/solana/contract/idl.go b/pkg/contract/solana/idl.go similarity index 98% rename from zetaclient/chains/solana/contract/idl.go rename to pkg/contract/solana/idl.go index 4be13ee31e..425d2af779 100644 --- a/zetaclient/chains/solana/contract/idl.go +++ b/pkg/contract/solana/idl.go @@ -1,4 +1,4 @@ -package contract +package solana type IDL struct { Address string `json:"address"` diff --git a/zetaclient/chains/solana/contract/types.go b/pkg/contract/solana/types.go similarity index 98% rename from zetaclient/chains/solana/contract/types.go rename to pkg/contract/solana/types.go index d75845ff0f..8b66b5f383 100644 --- a/zetaclient/chains/solana/contract/types.go +++ b/pkg/contract/solana/types.go @@ -1,4 +1,4 @@ -package contract +package solana // PdaInfo represents the PDA for the gateway program type PdaInfo struct { diff --git a/x/observer/genesis.go b/x/observer/genesis.go index d39a771535..366424a062 100644 --- a/x/observer/genesis.go +++ b/x/observer/genesis.go @@ -26,12 +26,15 @@ func InitGenesis(ctx sdk.Context, k keeper.Keeper, genState types.GenesisState) btcChainParams.IsSupported = true goerliChainParams := types.GetDefaultGoerliLocalnetChainParams() goerliChainParams.IsSupported = true + solanaChainParams := types.GetDefaultSolanaLocalnetChainParams() + solanaChainParams.IsSupported = true zetaPrivnetChainParams := types.GetDefaultZetaPrivnetChainParams() zetaPrivnetChainParams.IsSupported = true k.SetChainParamsList(ctx, types.ChainParamsList{ ChainParams: []*types.ChainParams{ btcChainParams, goerliChainParams, + solanaChainParams, zetaPrivnetChainParams, }, }) diff --git a/x/observer/types/chain_params.go b/x/observer/types/chain_params.go index 665be3de98..b2cbb63c32 100644 --- a/x/observer/types/chain_params.go +++ b/x/observer/types/chain_params.go @@ -11,6 +11,7 @@ import ( "github.com/pkg/errors" "github.com/zeta-chain/zetacore/pkg/chains" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" ) const ( @@ -313,6 +314,25 @@ func GetDefaultBtcRegtestChainParams() *ChainParams { IsSupported: false, } } +func GetDefaultSolanaLocalnetChainParams() *ChainParams { + return &ChainParams{ + ChainId: chains.SolanaLocalnet.ChainId, + ConfirmationCount: 32, + ZetaTokenContractAddress: zeroAddress, + ConnectorContractAddress: zeroAddress, + Erc20CustodyContractAddress: zeroAddress, + GasPriceTicker: 100, + WatchUtxoTicker: 0, + InboundTicker: 5, + OutboundTicker: 5, + OutboundScheduleInterval: 10, + OutboundScheduleLookahead: 10, + BallotThreshold: DefaultBallotThreshold, + MinObserverDelegation: DefaultMinObserverDelegation, + IsSupported: false, + GatewayAddress: solanacontract.SolanaGatewayProgramID, + } +} func GetDefaultGoerliLocalnetChainParams() *ChainParams { return &ChainParams{ ChainId: chains.GoerliLocalnet.ChainId, diff --git a/zetaclient/chains/base/observer.go b/zetaclient/chains/base/observer.go index 73babb7aca..6679fad69e 100644 --- a/zetaclient/chains/base/observer.go +++ b/zetaclient/chains/base/observer.go @@ -402,15 +402,14 @@ func (ob *Observer) ReadLastBlockScannedFromDB() (uint64, error) { // 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) { +func (ob *Observer) LoadLastTxScanned() { // 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.logger.Chain.Info().Msgf("LoadLastTxScanned: envvar %s is set; scan from tx %s", envvar, scanFromTx) ob.WithLastTxScanned(scanFromTx) return } @@ -419,7 +418,7 @@ func (ob *Observer) LoadLastTxScanned(logger zerolog.Logger) { 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) + ob.logger.Chain.Info().Msgf("LoadLastTxScanned: last scanned tx not found in db for chain %d", ob.chain.ChainId) return } ob.WithLastTxScanned(txHash) diff --git a/zetaclient/chains/base/observer_test.go b/zetaclient/chains/base/observer_test.go index 8ba8229049..42ab65882c 100644 --- a/zetaclient/chains/base/observer_test.go +++ b/zetaclient/chains/base/observer_test.go @@ -431,7 +431,7 @@ func TestLoadLastTxScanned(t *testing.T) { ob.WriteLastTxScannedToDB(lastTx) // read last tx scanned - ob.LoadLastTxScanned(log.Logger) + ob.LoadLastTxScanned() require.NoError(t, err) require.EqualValues(t, lastTx, ob.LastTxScanned()) }) @@ -443,7 +443,7 @@ func TestLoadLastTxScanned(t *testing.T) { require.NoError(t, err) // read last tx scanned - ob.LoadLastTxScanned(log.Logger) + ob.LoadLastTxScanned() require.NoError(t, err) require.Empty(t, ob.LastTxScanned()) }) @@ -462,7 +462,7 @@ func TestLoadLastTxScanned(t *testing.T) { os.Setenv(envvar, otherTx) // read last block scanned - ob.LoadLastTxScanned(log.Logger) + ob.LoadLastTxScanned() require.NoError(t, err) require.EqualValues(t, otherTx, ob.LastTxScanned()) }) @@ -530,11 +530,13 @@ func TestPostVoteInbound(t *testing.T) { // create mock zetacore client zetacoreClient := mocks.NewZetacoreClient(t) + zetacoreClient.WithPostVoteInbound("", "sampleBallotIndex") ob = ob.WithZetacoreClient(zetacoreClient) // post vote inbound msg := sample.InboundVote(coin.CoinType_Gas, chains.Ethereum.ChainId, chains.ZetaChainMainnet.ChainId) - _, err := ob.PostVoteInbound(context.TODO(), &msg, 100000) + ballot, err := ob.PostVoteInbound(context.TODO(), &msg, 100000) require.NoError(t, err) + require.Equal(t, "sampleBallotIndex", ballot) }) } diff --git a/zetaclient/chains/evm/observer/observer_test.go b/zetaclient/chains/evm/observer/observer_test.go index cc7c9f0a68..c24b5da8f7 100644 --- a/zetaclient/chains/evm/observer/observer_test.go +++ b/zetaclient/chains/evm/observer/observer_test.go @@ -62,6 +62,7 @@ func getZetacoreContext( []chains.Chain{evmChain}, evmChainParamsMap, nil, + nil, "", *sample.CrosschainFlags(), []chains.Chain{}, diff --git a/zetaclient/chains/solana/observer/db.go b/zetaclient/chains/solana/observer/db.go index 1030bdbe99..91757910d7 100644 --- a/zetaclient/chains/solana/observer/db.go +++ b/zetaclient/chains/solana/observer/db.go @@ -16,14 +16,14 @@ func (ob *Observer) LoadDB(dbPath string) error { return errors.Wrapf(err, "error OpenDB for chain %d", ob.Chain().ChainId) } - ob.Observer.LoadLastTxScanned(ob.Logger().Chain) + ob.Observer.LoadLastTxScanned() return nil } // LoadLastTxScanned loads the last scanned tx from the database. func (ob *Observer) LoadLastTxScanned() error { - ob.Observer.LoadLastTxScanned(ob.Logger().Chain) + ob.Observer.LoadLastTxScanned() ob.Logger().Chain.Info().Msgf("chain %d starts scanning from tx %s", ob.Chain().ChainId, ob.LastTxScanned()) return nil diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index 1fd82c2aae..86c10e28de 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -15,8 +15,8 @@ import ( "github.com/zeta-chain/zetacore/pkg/coin" "github.com/zeta-chain/zetacore/pkg/constant" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" - contract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" solanarpc "github.com/zeta-chain/zetacore/zetaclient/chains/solana/rpc" "github.com/zeta-chain/zetacore/zetaclient/compliance" zctx "github.com/zeta-chain/zetacore/zetaclient/context" @@ -242,14 +242,14 @@ func (ob *Observer) ParseInboundAsDeposit( instruction := tx.Message.Instructions[instructionIndex] // try deserializing instruction as a 'deposit' - var inst contract.DepositInstructionParams + var inst solanacontract.DepositInstructionParams err := borsh.Deserialize(&inst, instruction.Data) if err != nil { return nil, nil } // check if the instruction is a deposit or not - if inst.Discriminator != contract.DiscriminatorDeposit() { + if inst.Discriminator != solanacontract.DiscriminatorDeposit() { return nil, nil } @@ -281,8 +281,8 @@ func (ob *Observer) ParseInboundAsDeposit( // 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) != contract.AccountsNumDeposit { - return "", fmt.Errorf("want %d accounts, got %d", contract.AccountsNumDeposit, len(inst.Accounts)) + if len(inst.Accounts) != solanacontract.AccountsNumDeposit { + return "", fmt.Errorf("want %d accounts, got %d", solanacontract.AccountsNumDeposit, len(inst.Accounts)) } // the accounts are [signer, pda, system_program, gateway_program] diff --git a/zetaclient/chains/solana/observer/inbound_test.go b/zetaclient/chains/solana/observer/inbound_test.go index 60f9427c4a..cedc52f86a 100644 --- a/zetaclient/chains/solana/observer/inbound_test.go +++ b/zetaclient/chains/solana/observer/inbound_test.go @@ -33,7 +33,9 @@ func Test_FilterInboundEventAndVote(t *testing.T) { // create observer chainParams := sample.ChainParams(chain.ChainId) chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" - zetacoreClient := mocks.NewZetacoreClient(t).WithKeys(&keys.Keys{}) + zetacoreClient := mocks.NewZetacoreClient(t) + zetacoreClient.WithKeys(&keys.Keys{}).WithZetaChain().WithPostVoteInbound("", "") + dbpath := sample.CreateTempDir(t) ob, err := observer.NewObserver(chain, nil, *chainParams, zetacoreClient, nil, dbpath, base.DefaultLogger(), nil) require.NoError(t, err) @@ -88,7 +90,9 @@ func Test_BuildInboundVoteMsgFromEvent(t *testing.T) { chain := chains.SolanaDevnet params := sample.ChainParams(chain.ChainId) params.GatewayAddress = sample.SolanaAddress(t) - zetacoreClient := mocks.NewZetacoreClient(t).WithKeys(&keys.Keys{}) + zetacoreClient := mocks.NewZetacoreClient(t) + zetacoreClient.WithKeys(&keys.Keys{}).WithZetaChain().WithPostVoteInbound("", "") + dbpath := sample.CreateTempDir(t) ob, err := observer.NewObserver(chain, nil, *params, zetacoreClient, nil, dbpath, base.DefaultLogger(), nil) require.NoError(t, err) diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index f4f8718c79..a1cc2a51fb 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -7,10 +7,10 @@ import ( "github.com/zeta-chain/zetacore/pkg/bg" "github.com/zeta-chain/zetacore/pkg/chains" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" observertypes "github.com/zeta-chain/zetacore/x/observer/types" "github.com/zeta-chain/zetacore/zetaclient/chains/base" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" - contract "github.com/zeta-chain/zetacore/zetaclient/chains/solana/contract" "github.com/zeta-chain/zetacore/zetaclient/metrics" ) @@ -65,7 +65,7 @@ func NewObserver( } // compute gateway PDA - seed := []byte(contract.PDASeed) + seed := []byte(solanacontract.PDASeed) ob.pdaID, _, err = solana.FindProgramAddress([][]byte{seed}, ob.gatewayID) if err != nil { return nil, err diff --git a/zetaclient/config/config_chain.go b/zetaclient/config/config_chain.go index 2da362d19f..342574764f 100644 --- a/zetaclient/config/config_chain.go +++ b/zetaclient/config/config_chain.go @@ -43,6 +43,7 @@ func New(setDefaults bool) Config { if setDefaults { cfg.BitcoinConfig = bitcoinConfigRegnet() + cfg.SolanaConfig = solanaConfigLocalnet() cfg.EVMChainConfigs = evmChainsConfigs() } @@ -59,6 +60,13 @@ func bitcoinConfigRegnet() BTCConfig { } } +// solanaConfigLocalnet contains config for Solana localnet +func solanaConfigLocalnet() SolanaConfig { + return SolanaConfig{ + Endpoint: "http://solana:8899", + } +} + // evmChainsConfigs contains EVM chain configs // it contains list of EVM chains with empty endpoint except for localnet func evmChainsConfigs() map[int64]EVMConfig { diff --git a/zetaclient/config/types.go b/zetaclient/config/types.go index 4defd0df49..b9535f3937 100644 --- a/zetaclient/config/types.go +++ b/zetaclient/config/types.go @@ -82,6 +82,7 @@ type Config struct { HsmMode bool `json:"HsmMode"` HsmHotKey string `json:"HsmHotKey"` + // chain configs EVMChainConfigs map[int64]EVMConfig `json:"EVMChainConfigs"` BitcoinConfig BTCConfig `json:"BitcoinConfig"` SolanaConfig SolanaConfig `json:"SolanaConfig"` @@ -123,11 +124,10 @@ func (c Config) GetBTCConfig() (BTCConfig, bool) { // GetSolanaConfig returns the Solana config func (c Config) GetSolanaConfig() (SolanaConfig, bool) { - // FIXME_SOLANA: config this - solConfig := SolanaConfig{ - Endpoint: "http://solana:8899", - } - return solConfig, solConfig != (SolanaConfig{}) + c.mu.RLock() + defer c.mu.RUnlock() + + return c.SolanaConfig, c.SolanaConfig != (SolanaConfig{}) } // String returns the string representation of the config diff --git a/zetaclient/context/app.go b/zetaclient/context/app.go index 8e17b9dcda..139d7c5bef 100644 --- a/zetaclient/context/app.go +++ b/zetaclient/context/app.go @@ -2,6 +2,7 @@ package context import ( + "fmt" "sort" "sync" @@ -22,6 +23,7 @@ type AppContext struct { chainsEnabled []chains.Chain evmChainParams map[int64]*observertypes.ChainParams bitcoinChainParams *observertypes.ChainParams + solanaChainParams *observertypes.ChainParams currentTssPubkey string crosschainFlags observertypes.CrosschainFlags @@ -49,6 +51,12 @@ func New(cfg config.Config, logger zerolog.Logger) *AppContext { bitcoinChainParams = &observertypes.ChainParams{} } + var solanaChainParams *observertypes.ChainParams + _, found = cfg.GetSolanaConfig() + if found { + solanaChainParams = &observertypes.ChainParams{} + } + return &AppContext{ config: cfg, logger: logger.With().Str("module", "appcontext").Logger(), @@ -56,6 +64,7 @@ func New(cfg config.Config, logger zerolog.Logger) *AppContext { chainsEnabled: []chains.Chain{}, evmChainParams: evmChainParams, bitcoinChainParams: bitcoinChainParams, + solanaChainParams: solanaChainParams, crosschainFlags: observertypes.CrosschainFlags{}, blockHeaderEnabledChains: []lightclienttypes.HeaderSupportedChain{}, @@ -70,17 +79,6 @@ 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() @@ -93,6 +91,18 @@ func (a *AppContext) GetBTCChainAndConfig() (chains.Chain, config.BTCConfig, boo return btcChain, btcConfig, true } +// GetSolanaChainAndConfig returns solana chain and config if enabled +func (a *AppContext) GetSolanaChainAndConfig() (chains.Chain, config.SolanaConfig, bool) { + solConfig, configEnabled := a.Config().GetSolanaConfig() + solChain, _, paramsEnabled := a.GetSolanaChainParams() + + if !configEnabled || !paramsEnabled { + return chains.Chain{}, config.SolanaConfig{}, false + } + + return solChain, solConfig, true +} + // IsOutboundObservationEnabled returns true if the chain is supported and outbound flag is enabled func (a *AppContext) IsOutboundObservationEnabled(chainParams observertypes.ChainParams) bool { flags := a.GetCrossChainFlags() @@ -184,7 +194,8 @@ func (a *AppContext) GetBTCChainParams() (chains.Chain, *observertypes.ChainPara a.mu.RLock() defer a.mu.RUnlock() - if a.bitcoinChainParams == nil { // bitcoin is not enabled + // bitcoin is not enabled + if a.bitcoinChainParams == nil { return chains.Chain{}, nil, false } @@ -196,6 +207,25 @@ func (a *AppContext) GetBTCChainParams() (chains.Chain, *observertypes.ChainPara return chain, a.bitcoinChainParams, true } +// GetSolanaChainParams returns (chain, chain params, found) for solana chain +func (a *AppContext) GetSolanaChainParams() (chains.Chain, *observertypes.ChainParams, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + + // solana is not enabled + if a.solanaChainParams == nil { + return chains.Chain{}, nil, false + } + + chain, found := chains.GetChainFromChainID(a.solanaChainParams.ChainId, a.additionalChain) + if !found { + fmt.Printf("solana Chain %d not found", a.solanaChainParams.ChainId) + return chains.Chain{}, nil, false + } + + return chain, a.solanaChainParams, true +} + // GetCrossChainFlags returns crosschain flags func (a *AppContext) GetCrossChainFlags() observertypes.CrosschainFlags { a.mu.RLock() @@ -240,6 +270,7 @@ func (a *AppContext) Update( newChains []chains.Chain, evmChainParams map[int64]*observertypes.ChainParams, btcChainParams *observertypes.ChainParams, + solChainParams *observertypes.ChainParams, tssPubKey string, crosschainFlags observertypes.CrosschainFlags, additionalChains []chains.Chain, @@ -280,6 +311,11 @@ func (a *AppContext) Update( a.bitcoinChainParams = btcChainParams } + // update chain params for solana if it has config in file + if a.solanaChainParams != nil && solChainParams != nil { + a.solanaChainParams = solChainParams + } + // update core params for evm chains we have configs in file for _, params := range evmChainParams { _, found := a.evmChainParams[params.ChainId] diff --git a/zetaclient/context/app_test.go b/zetaclient/context/app_test.go index 39847e2097..1786242b20 100644 --- a/zetaclient/context/app_test.go +++ b/zetaclient/context/app_test.go @@ -163,6 +163,7 @@ func TestAppContextUpdate(t *testing.T) { enabledChainsToUpdate, evmChainParamsToUpdate, btcChainParamsToUpdate, + nil, tssPubKeyToUpdate, *crosschainFlags, []chains.Chain{}, @@ -264,6 +265,7 @@ func TestAppContextUpdate(t *testing.T) { enabledChainsToUpdate, evmChainParamsToUpdate, btcChainParamsToUpdate, + nil, tssPubKeyToUpdate, *crosschainFlags, []chains.Chain{}, @@ -409,6 +411,7 @@ func TestGetBTCChainAndConfig(t *testing.T) { []chains.Chain{}, nil, &observertypes.ChainParams{ChainId: 123}, + nil, "", observertypes.CrosschainFlags{}, []chains.Chain{}, @@ -427,6 +430,7 @@ func TestGetBTCChainAndConfig(t *testing.T) { []chains.Chain{}, nil, &observertypes.ChainParams{ChainId: chains.BitcoinMainnet.ChainId}, + nil, "", observertypes.CrosschainFlags{}, []chains.Chain{}, @@ -471,6 +475,7 @@ func TestGetBlockHeaderEnabledChains(t *testing.T) { []chains.Chain{}, nil, &observertypes.ChainParams{ChainId: chains.BitcoinMainnet.ChainId}, + nil, "", observertypes.CrosschainFlags{}, []chains.Chain{}, @@ -513,6 +518,7 @@ func TestGetAdditionalChains(t *testing.T) { []chains.Chain{}, nil, &observertypes.ChainParams{}, + nil, "", observertypes.CrosschainFlags{}, additionalChains, @@ -553,6 +559,7 @@ func makeAppContext( []chains.Chain{evmChain}, evmChainParamsMap, nil, + nil, "", ccFlags, []chains.Chain{}, diff --git a/zetaclient/orchestrator/orchestrator_test.go b/zetaclient/orchestrator/orchestrator_test.go index 8637834a6e..06e7aaf625 100644 --- a/zetaclient/orchestrator/orchestrator_test.go +++ b/zetaclient/orchestrator/orchestrator_test.go @@ -80,6 +80,7 @@ func CreateAppContext( []chains.Chain{evmChain, btcChain}, evmChainParamsMap, btcChainParams, + nil, "", *ccFlags, []chains.Chain{}, diff --git a/zetaclient/zetacore/client.go b/zetaclient/zetacore/client.go index 2874408451..9ab37e231e 100644 --- a/zetaclient/zetacore/client.go +++ b/zetaclient/zetacore/client.go @@ -381,6 +381,7 @@ func (c *Client) UpdateZetacoreContext( newEVMParams := make(map[int64]*observertypes.ChainParams) var newBTCParams *observertypes.ChainParams + var newSolanaParams *observertypes.ChainParams // check and update chain params for each chain for _, chainParam := range chainParams { @@ -391,6 +392,8 @@ func (c *Client) UpdateZetacoreContext( } if chains.IsBitcoinChain(chainParam.ChainId, additionalChains) { newBTCParams = chainParam + } else if chains.IsSolanaChain(chainParam.ChainId, additionalChains) { + newSolanaParams = chainParam } else if chains.IsEVMChain(chainParam.ChainId, additionalChains) { newEVMParams[chainParam.ChainId] = chainParam } @@ -436,6 +439,7 @@ func (c *Client) UpdateZetacoreContext( newChains, newEVMParams, newBTCParams, + newSolanaParams, tssPubKey, crosschainFlags, additionalChains, From 97fd39920f82372ac94065c92c07d9633d93eeba Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Tue, 16 Jul 2024 23:06:20 -0500 Subject: [PATCH 23/37] add issue link for TODO --- zetaclient/types/event.go | 1 + 1 file changed, 1 insertion(+) diff --git a/zetaclient/types/event.go b/zetaclient/types/event.go index 8bff59c5b5..1f498af67f 100644 --- a/zetaclient/types/event.go +++ b/zetaclient/types/event.go @@ -6,6 +6,7 @@ import ( // InboundEvent represents an inbound event // TODO: we should consider using this generic struct when it applies (e.g. for Bitcoin, Solana, etc.) +// https://github.com/zeta-chain/node/issues/2495 type InboundEvent struct { // SenderChainID is the chain ID of the sender SenderChainID int64 From b203fd4a1bddbe0c7c22ffc8fc89255145a19b0d Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Tue, 16 Jul 2024 23:17:06 -0500 Subject: [PATCH 24/37] remove panics --- pkg/contract/solana/types.go | 2 +- zetaclient/chains/solana/observer/outbound.go | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pkg/contract/solana/types.go b/pkg/contract/solana/types.go index 8b66b5f383..eede621c06 100644 --- a/pkg/contract/solana/types.go +++ b/pkg/contract/solana/types.go @@ -15,7 +15,7 @@ type PdaInfo struct { Authority [32]byte // ChainId is the chain ID for the gateway program - // TODO: this field exists in latest version of gateway program, but not in the current e2e test program + // Note: this field exists in latest version of gateway program, but not in the current e2e test program // ChainId uint64 } diff --git a/zetaclient/chains/solana/observer/outbound.go b/zetaclient/chains/solana/observer/outbound.go index e744e001e9..a75641e072 100644 --- a/zetaclient/chains/solana/observer/outbound.go +++ b/zetaclient/chains/solana/observer/outbound.go @@ -10,8 +10,7 @@ import ( // GetTxID returns a unique id for Solana outbound func (ob *Observer) GetTxID(_ uint64) string { - //TODO implement me - panic("implement me") + return "" } // IsOutboundProcessed checks outbound status and returns (isIncluded, isConfirmed, error) @@ -20,6 +19,5 @@ func (ob *Observer) IsOutboundProcessed( _ *types.CrossChainTx, _ zerolog.Logger, ) (bool, bool, error) { - //TODO implement me - panic("implement me") + return false, false, nil } From 2743f9dc1b608f81928dc99e067e9f65fcc6d716 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 17 Jul 2024 10:38:10 -0500 Subject: [PATCH 25/37] move Solana gateway program initialization to the contract setup phase --- cmd/zetae2e/local/local.go | 3 +- e2e/e2etests/e2etests.go | 11 +-- e2e/e2etests/test_solana_initialize.go | 78 ------------------- e2e/runner/setup_solana.go | 66 ++++++++++++++++ e2e/runner/setup_zeta.go | 10 --- zetaclient/chains/base/observer.go | 4 - .../chains/bitcoin/observer/observer.go | 2 +- zetaclient/chains/evm/observer/observer.go | 2 +- zetaclient/chains/solana/observer/observer.go | 2 +- 9 files changed, 72 insertions(+), 106 deletions(-) delete mode 100644 e2e/e2etests/test_solana_initialize.go diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 7060c28de4..4007e4fc12 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -171,7 +171,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { deployerRunner.SetupEVM(contractsDeployed, true) deployerRunner.SetZEVMContracts() - deployerRunner.SetSolanaContracts() + deployerRunner.SetSolanaContracts(conf.AdditionalAccounts.UserSolana.SolanaPrivateKey.String()) noError(deployerRunner.FundEmissionsPool()) deployerRunner.MintERC20OnEvm(10000) @@ -234,7 +234,6 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestMessagePassingEVMtoZEVMRevertFailName, } solanaTests := []string{ - e2etests.TestSolanaIntializeGatewayName, e2etests.TestSolanaDepositName, } diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 460e8fc312..d99f42c42a 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -54,8 +54,7 @@ const ( /* Solana tests */ - TestSolanaIntializeGatewayName = "solana_initialize_gateway" - TestSolanaDepositName = "solana_deposit" + TestSolanaDepositName = "solana_deposit" /* Bitcoin tests @@ -332,15 +331,9 @@ var AllE2ETests = []runner.E2ETest{ /* Solana tests */ - runner.NewE2ETest( - TestSolanaIntializeGatewayName, - "initialize Solana gateway", - []runner.ArgDefinition{}, - TestSolanaInitializeGateway, - ), runner.NewE2ETest( TestSolanaDepositName, - "deposit Sol into ZEVM", + "deposit SOL into ZEVM", []runner.ArgDefinition{ {Description: "amount in SOL", DefaultValue: "0.1"}, }, diff --git a/e2e/e2etests/test_solana_initialize.go b/e2e/e2etests/test_solana_initialize.go deleted file mode 100644 index e40968518d..0000000000 --- a/e2e/e2etests/test_solana_initialize.go +++ /dev/null @@ -1,78 +0,0 @@ -package e2etests - -import ( - "context" - - ethcommon "github.com/ethereum/go-ethereum/common" - "github.com/gagliardetto/solana-go" - "github.com/gagliardetto/solana-go/rpc" - "github.com/near/borsh-go" - "github.com/stretchr/testify/require" - - "github.com/zeta-chain/zetacore/e2e/runner" - "github.com/zeta-chain/zetacore/pkg/chains" - solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" -) - -func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { - // no arguments expected - require.Len(r, args, 0, "solana gateway initialization test should have no arguments") - - // print the solana node version - client := r.SolanaClient - res, err := client.GetVersion(context.Background()) - require.NoError(r, err) - r.Logger.Info("solana version: %+v", res) - - // get deployer account balance - privkey := solana.MustPrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) - bal, err := client.GetBalance(r.Ctx, privkey.PublicKey(), rpc.CommitmentFinalized) - require.NoError(r, err) - r.Logger.Info("deployer address: %s, balance: %f SOL", privkey.PublicKey().String(), float64(bal.Value)/1e9) - - // compute the gateway PDA address - pdaComputed := r.ComputePdaAddress() - programID := r.GatewayProgram - - // create 'initialize' instruction - var inst solana.GenericInstruction - accountSlice := []*solana.AccountMeta{} - accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) - accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) - accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) - accountSlice = append(accountSlice, solana.Meta(programID)) - inst.ProgID = programID - inst.AccountValues = accountSlice - - inst.DataBytes, err = borsh.Serialize(solanacontract.InitializeParams{ - Discriminator: solanacontract.DiscriminatorInitialize(), - TssAddress: r.TSSAddress, - ChainID: uint64(chains.SolanaLocalnet.ChainId), - }) - require.NoError(r, err) - - // create and sign the transaction - signedTx := r.CreateSignedTransaction([]solana.Instruction{&inst}, privkey) - - // broadcast the transaction and wait for finalization - _, out := r.BroadcastTxSync(signedTx) - r.Logger.Info("initialize logs: %v", out.Meta.LogMessages) - - // retrieve the PDA account info - pdaInfo, err := client.GetAccountInfo(r.Ctx, pdaComputed) - require.NoError(r, err) - - // deserialize the PDA info - pda := solanacontract.PdaInfo{} - err = borsh.Deserialize(&pda, pdaInfo.Bytes()) - require.NoError(r, err) - tssAddress := ethcommon.BytesToAddress(pda.TssAddress[:]) - - // check the TSS address - require.Equal(r, r.TSSAddress, tssAddress, "TSS address mismatch") - - // show the PDA balance - balance, err := client.GetBalance(r.Ctx, pdaComputed, rpc.CommitmentConfirmed) - require.NoError(r, err) - r.Logger.Info("initial PDA balance: %d lamports", balance.Value) -} diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index da6bd099a9..9390b5ead9 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -3,7 +3,14 @@ package runner import ( "time" + ethcommon "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/zetacore/pkg/chains" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" ) func (r *E2ERunner) SetupSolanaAccount() { @@ -23,3 +30,62 @@ func (r *E2ERunner) SetSolanaAddress() { r.Logger.Info("SolanaDeployerAddress: %s", r.SolanaDeployerAddress) } + +// SetSolanaContracts set Solana contracts +func (r *E2ERunner) SetSolanaContracts(deployerPrivateKey string) { + r.Logger.Print("⚙️ setting up Solana contracts") + + // set Solana contracts + r.GatewayProgram = solana.MustPublicKeyFromBase58(solanacontract.SolanaGatewayProgramID) + + // get deployer account balance + privkey := solana.MustPrivateKeyFromBase58(deployerPrivateKey) + bal, err := r.SolanaClient.GetBalance(r.Ctx, privkey.PublicKey(), rpc.CommitmentFinalized) + require.NoError(r, err) + r.Logger.Info("deployer address: %s, balance: %f SOL", privkey.PublicKey().String(), float64(bal.Value)/1e9) + + // compute the gateway PDA address + pdaComputed := r.ComputePdaAddress() + + // create 'initialize' instruction + var inst solana.GenericInstruction + accountSlice := []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + accountSlice = append(accountSlice, solana.Meta(r.GatewayProgram)) + inst.ProgID = r.GatewayProgram + inst.AccountValues = accountSlice + + inst.DataBytes, err = borsh.Serialize(solanacontract.InitializeParams{ + Discriminator: solanacontract.DiscriminatorInitialize(), + TssAddress: r.TSSAddress, + ChainID: uint64(chains.SolanaLocalnet.ChainId), + }) + require.NoError(r, err) + + // create and sign the transaction + signedTx := r.CreateSignedTransaction([]solana.Instruction{&inst}, privkey) + + // broadcast the transaction and wait for finalization + _, out := r.BroadcastTxSync(signedTx) + r.Logger.Info("initialize logs: %v", out.Meta.LogMessages) + + // retrieve the PDA account info + pdaInfo, err := r.SolanaClient.GetAccountInfo(r.Ctx, pdaComputed) + require.NoError(r, err) + + // deserialize the PDA info + pda := solanacontract.PdaInfo{} + err = borsh.Deserialize(&pda, pdaInfo.Bytes()) + require.NoError(r, err) + tssAddress := ethcommon.BytesToAddress(pda.TssAddress[:]) + + // check the TSS address + require.Equal(r, r.TSSAddress, tssAddress, "TSS address mismatch") + + // show the PDA balance + balance, err := r.SolanaClient.GetBalance(r.Ctx, pdaComputed, rpc.CommitmentConfirmed) + require.NoError(r, err) + r.Logger.Print("initial PDA balance: %d lamports", balance.Value) +} diff --git a/e2e/runner/setup_zeta.go b/e2e/runner/setup_zeta.go index d21cf7d564..7007d60484 100644 --- a/e2e/runner/setup_zeta.go +++ b/e2e/runner/setup_zeta.go @@ -7,7 +7,6 @@ import ( "github.com/btcsuite/btcutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" ethcommon "github.com/ethereum/go-ethereum/common" - "github.com/gagliardetto/solana-go" "github.com/stretchr/testify/require" "github.com/zeta-chain/protocol-contracts/pkg/contracts/zevm/systemcontract.sol" "github.com/zeta-chain/protocol-contracts/pkg/contracts/zevm/wzeta.sol" @@ -22,7 +21,6 @@ import ( "github.com/zeta-chain/zetacore/e2e/txserver" e2eutils "github.com/zeta-chain/zetacore/e2e/utils" "github.com/zeta-chain/zetacore/pkg/chains" - solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" fungibletypes "github.com/zeta-chain/zetacore/x/fungible/types" observertypes "github.com/zeta-chain/zetacore/x/observer/types" ) @@ -67,14 +65,6 @@ func (r *E2ERunner) SetTSSAddresses() error { return nil } -// SetSolanaContracts set Solana contracts -func (r *E2ERunner) SetSolanaContracts() { - r.Logger.Print("⚙️ setting up Solana contracts") - - // set Solana contracts - r.GatewayProgram = solana.MustPublicKeyFromBase58(solanacontract.SolanaGatewayProgramID) -} - // SetZEVMContracts set contracts for the ZEVM func (r *E2ERunner) SetZEVMContracts() { r.Logger.Print("⚙️ deploying system contracts and ZRC20s on ZEVM") diff --git a/zetaclient/chains/base/observer.go b/zetaclient/chains/base/observer.go index 6679fad69e..4679dbd835 100644 --- a/zetaclient/chains/base/observer.go +++ b/zetaclient/chains/base/observer.go @@ -213,15 +213,11 @@ func (ob *Observer) WithLastBlockScanned(blockNumber uint64) *Observer { // 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 } diff --git a/zetaclient/chains/bitcoin/observer/observer.go b/zetaclient/chains/bitcoin/observer/observer.go index c9d8350781..2aea6e450f 100644 --- a/zetaclient/chains/bitcoin/observer/observer.go +++ b/zetaclient/chains/bitcoin/observer/observer.go @@ -45,7 +45,7 @@ const ( BigValueConfirmationCount = 6 ) -var _ interfaces.ChainObserver = &Observer{} +var _ interfaces.ChainObserver = (*Observer)(nil) // Logger contains list of loggers used by Bitcoin chain observer type Logger struct { diff --git a/zetaclient/chains/evm/observer/observer.go b/zetaclient/chains/evm/observer/observer.go index 126c8baf65..a6b69a78b1 100644 --- a/zetaclient/chains/evm/observer/observer.go +++ b/zetaclient/chains/evm/observer/observer.go @@ -30,7 +30,7 @@ import ( clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" ) -var _ interfaces.ChainObserver = &Observer{} +var _ interfaces.ChainObserver = (*Observer)(nil) // Observer is the observer for evm chains type Observer struct { diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index a1cc2a51fb..aa8950ae50 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -14,7 +14,7 @@ import ( "github.com/zeta-chain/zetacore/zetaclient/metrics" ) -var _ interfaces.ChainObserver = &Observer{} +var _ interfaces.ChainObserver = (*Observer)(nil) // Observer is the observer for the Solana chain type Observer struct { From 7032ee7e171261373ecf98860bd981ce453e310b Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 17 Jul 2024 11:16:21 -0500 Subject: [PATCH 26/37] refactor e2e clients creation; add context to Solana signature query --- cmd/zetae2e/config/clients.go | 86 +++++++++---------- cmd/zetae2e/config/config.go | 40 +++------ e2e/runner/setup_solana.go | 2 +- zetaclient/chains/solana/observer/inbound.go | 4 +- zetaclient/chains/solana/rpc/rpc.go | 8 +- zetaclient/chains/solana/rpc/rpc_live_test.go | 5 +- 6 files changed, 68 insertions(+), 77 deletions(-) diff --git a/cmd/zetae2e/config/clients.go b/cmd/zetae2e/config/clients.go index c207793739..db7753aede 100644 --- a/cmd/zetae2e/config/clients.go +++ b/cmd/zetae2e/config/clients.go @@ -19,72 +19,72 @@ import ( observertypes "github.com/zeta-chain/zetacore/x/observer/types" ) +// E2EClients contains all the RPC clients and gRPC clients for E2E tests +type E2EClients struct { + // the RPC clients for external chains in the localnet + BtcRPCClient *rpcclient.Client + SolanaClient *rpc.Client + EvmClient *ethclient.Client + EvmAuth *bind.TransactOpts + + // the gRPC clients for ZetaChain + CctxClient crosschaintypes.QueryClient + FungibleClient fungibletypes.QueryClient + AuthClient authtypes.QueryClient + BankClient banktypes.QueryClient + ObserverClient observertypes.QueryClient + LightClient lightclienttypes.QueryClient + + // the RPC clients for ZetaChain + ZevmClient *ethclient.Client + ZevmAuth *bind.TransactOpts +} + // getClientsFromConfig get clients from config func getClientsFromConfig(ctx context.Context, conf config.Config, account config.Account) ( - *rpcclient.Client, - *rpc.Client, - *ethclient.Client, - *bind.TransactOpts, - crosschaintypes.QueryClient, - fungibletypes.QueryClient, - authtypes.QueryClient, - banktypes.QueryClient, - observertypes.QueryClient, - lightclienttypes.QueryClient, - *ethclient.Client, - *bind.TransactOpts, + E2EClients, error, ) { if conf.RPCs.Solana == "" { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("solana rpc is empty") + return E2EClients{}, fmt.Errorf("solana rpc is empty") } solanaClient := rpc.New(conf.RPCs.Solana) if solanaClient == nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get solana client") + return E2EClients{}, fmt.Errorf("failed to get solana client") } btcRPCClient, err := getBtcClient(conf.RPCs.Bitcoin) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf( - "failed to get btc client: %w", - err, - ) + return E2EClients{}, fmt.Errorf("failed to get btc client: %w", err) } evmClient, evmAuth, err := getEVMClient(ctx, conf.RPCs.EVM, account) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf( - "failed to get evm client: %w", - err, - ) + return E2EClients{}, fmt.Errorf("failed to get evm client: %w", err) } cctxClient, fungibleClient, authClient, bankClient, observerClient, lightclientClient, err := getZetaClients( conf.RPCs.ZetaCoreGRPC, ) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf( - "failed to get zeta clients: %w", - err, - ) + return E2EClients{}, fmt.Errorf("failed to get zeta clients: %w", err) } zevmClient, zevmAuth, err := getEVMClient(ctx, conf.RPCs.Zevm, account) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf( - "failed to get zevm client: %w", - err, - ) + return E2EClients{}, fmt.Errorf("failed to get zevm client: %w", err) } - return btcRPCClient, - solanaClient, - evmClient, - evmAuth, - cctxClient, - fungibleClient, - authClient, - bankClient, - observerClient, - lightclientClient, - zevmClient, - zevmAuth, - nil + + return E2EClients{ + BtcRPCClient: btcRPCClient, + SolanaClient: solanaClient, + EvmClient: evmClient, + EvmAuth: evmAuth, + CctxClient: cctxClient, + FungibleClient: fungibleClient, + AuthClient: authClient, + BankClient: bankClient, + ObserverClient: observerClient, + LightClient: lightclientClient, + ZevmClient: zevmClient, + ZevmAuth: zevmAuth, + }, nil } // getBtcClient get btc client diff --git a/cmd/zetae2e/config/config.go b/cmd/zetae2e/config/config.go index deebb935f7..f8e0fddafb 100644 --- a/cmd/zetae2e/config/config.go +++ b/cmd/zetae2e/config/config.go @@ -18,20 +18,8 @@ func RunnerFromConfig( logger *runner.Logger, opts ...runner.E2ERunnerOption, ) (*runner.E2ERunner, error) { - // initialize clients - btcRPCClient, - solanaClient, - evmClient, - evmAuth, - cctxClient, - fungibleClient, - authClient, - bankClient, - observerClient, - lightClient, - zevmClient, - zevmAuth, - err := getClientsFromConfig(ctx, conf, account) + // initialize all clients for E2E tests + e2eClients, err := getClientsFromConfig(ctx, conf, account) if err != nil { return nil, fmt.Errorf("failed to get clients from config: %w", err) } @@ -42,18 +30,18 @@ func RunnerFromConfig( name, ctxCancel, account, - evmClient, - zevmClient, - cctxClient, - fungibleClient, - authClient, - bankClient, - observerClient, - lightClient, - evmAuth, - zevmAuth, - btcRPCClient, - solanaClient, + e2eClients.EvmClient, + e2eClients.ZevmClient, + e2eClients.CctxClient, + e2eClients.FungibleClient, + e2eClients.AuthClient, + e2eClients.BankClient, + e2eClients.ObserverClient, + e2eClients.LightClient, + e2eClients.EvmAuth, + e2eClients.ZevmAuth, + e2eClients.BtcRPCClient, + e2eClients.SolanaClient, logger, opts..., diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index 9390b5ead9..c9663dc3b8 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -87,5 +87,5 @@ func (r *E2ERunner) SetSolanaContracts(deployerPrivateKey string) { // show the PDA balance balance, err := r.SolanaClient.GetBalance(r.Ctx, pdaComputed, rpc.CommitmentConfirmed) require.NoError(r, err) - r.Logger.Print("initial PDA balance: %d lamports", balance.Value) + r.Logger.Info("initial PDA balance: %d lamports", balance.Value) } diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index 86c10e28de..adde70a042 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -76,7 +76,7 @@ func (ob *Observer) ObserveInbound(ctx context.Context) error { // scan from gateway 1st signature if last scanned tx is absent in the database // the 1st gateway signature is typically the program initialization if ob.LastTxScanned() == "" { - lastSig, err := solanarpc.GetFirstSignatureForAddress(ob.solClient, ob.gatewayID, pageLimit) + lastSig, err := solanarpc.GetFirstSignatureForAddress(ctx, ob.solClient, ob.gatewayID, pageLimit) if err != nil { return errors.Wrapf(err, "error GetFirstSignatureForAddress for chain %d address %s", chainID, ob.gatewayID) } @@ -85,7 +85,7 @@ func (ob *Observer) ObserveInbound(ctx context.Context) error { // get all signatures for the gateway address since last scanned signature lastSig := solana.MustSignatureFromBase58(ob.LastTxScanned()) - signatures, err := solanarpc.GetSignaturesForAddressUntil(ob.solClient, ob.gatewayID, lastSig, pageLimit) + signatures, err := solanarpc.GetSignaturesForAddressUntil(ctx, ob.solClient, ob.gatewayID, lastSig, pageLimit) if err != nil { ob.Logger().Inbound.Err(err).Msg("error GetSignaturesForAddressUntil") return err diff --git a/zetaclient/chains/solana/rpc/rpc.go b/zetaclient/chains/solana/rpc/rpc.go index 3add097c7b..d83846f1e1 100644 --- a/zetaclient/chains/solana/rpc/rpc.go +++ b/zetaclient/chains/solana/rpc/rpc.go @@ -18,6 +18,7 @@ const ( // GetFirstSignatureForAddress searches the first signature for the given address. // Note: make sure that the rpc provider used has enough transaction history. func GetFirstSignatureForAddress( + ctx context.Context, client interfaces.SolanaRPCClient, address solana.PublicKey, pageLimit int, @@ -26,7 +27,7 @@ func GetFirstSignatureForAddress( var lastSignature solana.Signature for { fetchedSignatures, err := client.GetSignaturesForAddressWithOpts( - context.TODO(), + ctx, address, &rpc.GetSignaturesForAddressOpts{ Limit: &pageLimit, @@ -62,6 +63,7 @@ func GetFirstSignatureForAddress( // GetSignaturesForAddressUntil searches for signatures for the given address until the given signature (exclusive). // Note: make sure that the rpc provider used has enough transaction history. func GetSignaturesForAddressUntil( + ctx context.Context, client interfaces.SolanaRPCClient, address solana.PublicKey, untilSig solana.Signature, @@ -72,7 +74,7 @@ func GetSignaturesForAddressUntil( // make sure that the 'untilSig' exists to prevent undefined behavior on GetSignaturesForAddressWithOpts _, err := client.GetTransaction( - context.TODO(), + ctx, untilSig, &rpc.GetTransactionOpts{Commitment: rpc.CommitmentFinalized}, ) @@ -83,7 +85,7 @@ func GetSignaturesForAddressUntil( // search backwards until we hit the 'untilSig' signature for { fetchedSignatures, err := client.GetSignaturesForAddressWithOpts( - context.TODO(), + ctx, address, &rpc.GetSignaturesForAddressOpts{ Limit: &pageLimit, diff --git a/zetaclient/chains/solana/rpc/rpc_live_test.go b/zetaclient/chains/solana/rpc/rpc_live_test.go index 7354585288..e0bec01e9d 100644 --- a/zetaclient/chains/solana/rpc/rpc_live_test.go +++ b/zetaclient/chains/solana/rpc/rpc_live_test.go @@ -1,6 +1,7 @@ package rpc_test import ( + "context" "os" "testing" @@ -25,7 +26,7 @@ func LiveTest_GetFirstSignatureForAddress(t *testing.T) { address := solana.MustPublicKeyFromBase58("2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s") // get the first signature for the address (one by one) - sig, err := rpc.GetFirstSignatureForAddress(client, address, 1) + sig, err := rpc.GetFirstSignatureForAddress(context.TODO(), client, address, 1) require.NoError(t, err) // assert @@ -45,7 +46,7 @@ func LiveTest_GetSignaturesForAddressUntil(t *testing.T) { ) // get all signatures for the address until the first signature (one by one) - sigs, err := rpc.GetSignaturesForAddressUntil(client, address, untilSig, 1) + sigs, err := rpc.GetSignaturesForAddressUntil(context.TODO(), client, address, untilSig, 1) require.NoError(t, err) // assert From 5d0ccbb54f9200f2bbd54662d8c9a504028584f3 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 17 Jul 2024 13:53:42 -0500 Subject: [PATCH 27/37] fix unit tests --- e2e/config/config.go | 1 + .../keeper/grpc_query_cctx_rate_limit_test.go | 15 +++++++++++++++ x/crosschain/keeper/utils_test.go | 4 ++++ x/observer/genesis_test.go | 6 ++++++ zetaclient/chains/solana/rpc/rpc_live_test.go | 2 +- 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/e2e/config/config.go b/e2e/config/config.go index bd1478a581..8e8006c042 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -207,6 +207,7 @@ func (a AdditionalAccounts) AsSlice() []Account { a.UserZetaTest, a.UserZEVMMPTest, a.UserBitcoin, + a.UserSolana, a.UserEther, a.UserMisc, a.UserAdmin, diff --git a/x/crosschain/keeper/grpc_query_cctx_rate_limit_test.go b/x/crosschain/keeper/grpc_query_cctx_rate_limit_test.go index 8dedb591e2..f08dcadadc 100644 --- a/x/crosschain/keeper/grpc_query_cctx_rate_limit_test.go +++ b/x/crosschain/keeper/grpc_query_cctx_rate_limit_test.go @@ -22,6 +22,9 @@ var ( // local btc chain ID btcChainID = getValidBtcChainID() + + // local solana chain ID + solanaChainID = getValidSolanaChainID() ) // createTestRateLimiterFlags creates a custom rate limiter flags @@ -458,6 +461,12 @@ func TestKeeper_RateLimiterInput(t *testing.T) { setCctxsInKeeper(ctx, *k, zk, tss, tt.btcPendingCctxs) zk.ObserverKeeper.SetPendingNonces(ctx, tt.btcPendingNonces) + // Set Solana chain pending nonce as zeros (to avoid error on ListPendingCctxWithinRateLimit) + zk.ObserverKeeper.SetPendingNonces(ctx, observertypes.PendingNonces{ + ChainId: solanaChainID, + Tss: tss.TssPubkey, + }) + // Set current block height ctx = ctx.WithBlockHeight(tt.currentHeight) @@ -1015,6 +1024,12 @@ func TestKeeper_ListPendingCctxWithinRateLimit(t *testing.T) { setCctxsInKeeper(ctx, *k, zk, tss, tt.btcPendingCctxs) zk.ObserverKeeper.SetPendingNonces(ctx, tt.btcPendingNonces) + // Set Solana chain pending nonce as zeros (to avoid error on ListPendingCctxWithinRateLimit) + zk.ObserverKeeper.SetPendingNonces(ctx, observertypes.PendingNonces{ + ChainId: solanaChainID, + Tss: tss.TssPubkey, + }) + // Set current block height ctx = ctx.WithBlockHeight(tt.currentHeight) diff --git a/x/crosschain/keeper/utils_test.go b/x/crosschain/keeper/utils_test.go index 6da6a501a2..ce5aa82447 100644 --- a/x/crosschain/keeper/utils_test.go +++ b/x/crosschain/keeper/utils_test.go @@ -41,6 +41,10 @@ func getValidBtcChainID() int64 { return getValidBTCChain().ChainId } +func getValidSolanaChainID() int64 { + return chains.SolanaLocalnet.ChainId +} + // getValidEthChainIDWithIndex get a valid eth chain id with index func getValidEthChainIDWithIndex(t *testing.T, index int) int64 { switch index { diff --git a/x/observer/genesis_test.go b/x/observer/genesis_test.go index 73edbc1946..78f0bea2c1 100644 --- a/x/observer/genesis_test.go +++ b/x/observer/genesis_test.go @@ -68,12 +68,15 @@ func TestGenesis(t *testing.T) { btcChainParams.IsSupported = true goerliChainParams := types.GetDefaultGoerliLocalnetChainParams() goerliChainParams.IsSupported = true + solanaChainParams := types.GetDefaultSolanaLocalnetChainParams() + solanaChainParams.IsSupported = true zetaPrivnetChainParams := types.GetDefaultZetaPrivnetChainParams() zetaPrivnetChainParams.IsSupported = true localnetChainParams := types.ChainParamsList{ ChainParams: []*types.ChainParams{ btcChainParams, goerliChainParams, + solanaChainParams, zetaPrivnetChainParams, }, } @@ -104,12 +107,15 @@ func TestGenesis(t *testing.T) { btcChainParams.IsSupported = true goerliChainParams := types.GetDefaultGoerliLocalnetChainParams() goerliChainParams.IsSupported = true + solanaChainParams := types.GetDefaultSolanaLocalnetChainParams() + solanaChainParams.IsSupported = true zetaPrivnetChainParams := types.GetDefaultZetaPrivnetChainParams() zetaPrivnetChainParams.IsSupported = true localnetChainParams := types.ChainParamsList{ ChainParams: []*types.ChainParams{ btcChainParams, goerliChainParams, + solanaChainParams, zetaPrivnetChainParams, }, } diff --git a/zetaclient/chains/solana/rpc/rpc_live_test.go b/zetaclient/chains/solana/rpc/rpc_live_test.go index e0bec01e9d..c8455588ff 100644 --- a/zetaclient/chains/solana/rpc/rpc_live_test.go +++ b/zetaclient/chains/solana/rpc/rpc_live_test.go @@ -14,7 +14,7 @@ import ( // Test_SolanaRPCLive is a phony test to run all live tests func Test_SolanaRPCLive(t *testing.T) { // LiveTest_GetFirstSignatureForAddress(t) - LiveTest_GetSignaturesForAddressUntil(t) + // LiveTest_GetSignaturesForAddressUntil(t) } func LiveTest_GetFirstSignatureForAddress(t *testing.T) { From 77953c8bdc6ed0af3eb1c32d5b4d4ed7be9febc0 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 17 Jul 2024 15:08:44 -0500 Subject: [PATCH 28/37] added inbound last_scanned_block_number metrics --- zetaclient/chains/base/observer.go | 7 ++++++- zetaclient/chains/solana/observer/inbound.go | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/zetaclient/chains/base/observer.go b/zetaclient/chains/base/observer.go index 4679dbd835..b3eb554a96 100644 --- a/zetaclient/chains/base/observer.go +++ b/zetaclient/chains/base/observer.go @@ -421,8 +421,13 @@ func (ob *Observer) LoadLastTxScanned() { } // SaveLastTxScanned saves the last scanned tx hash to memory and database. -func (ob *Observer) SaveLastTxScanned(txHash string) error { +func (ob *Observer) SaveLastTxScanned(txHash string, slot uint64) error { + // save last scanned tx to memory ob.WithLastTxScanned(txHash) + + // update last_scanned_block_number metrics + ob.WithLastBlockScanned(slot) + return ob.WriteLastTxScannedToDB(txHash) } diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index adde70a042..f78c18aa4f 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -116,13 +116,15 @@ func (ob *Observer) ObserveInbound(ctx context.Context) error { } // signature scanned; save last scanned signature to both memory and db, ignore db error - if err := ob.SaveLastTxScanned(sigString); err != nil { + if err := ob.SaveLastTxScanned(sigString, sig.Slot); err != nil { ob.Logger(). Inbound.Error(). Err(err). Msgf("ObserveInbound: error saving last sig %s for chain %d", sigString, chainID) } - ob.Logger().Inbound.Info().Msgf("ObserveInbound: last scanned sig for chain %d is %s", chainID, sigString) + ob.Logger(). + Inbound.Info(). + Msgf("ObserveInbound: last scanned sig is %s for chain %d in slot %d", sigString, chainID, sig.Slot) // take a rest if max signatures per ticker is reached if len(signatures)-i >= MaxSignaturesPerTicker { From 39497f48acdedc5ac7ceab10d5fc70700afd90ba Mon Sep 17 00:00:00 2001 From: Alex Gartner Date: Wed, 17 Jul 2024 15:36:53 -0700 Subject: [PATCH 29/37] integrate solana tests into CI --- .github/workflows/e2e.yml | 12 +++++++++++- Makefile | 5 +++++ cmd/zetae2e/local/local.go | 14 +++++++++----- contrib/localnet/docker-compose.yml | 3 +++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index aab15adf8f..2bcdfa6fdb 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,10 @@ on: type: boolean required: false default: false + solana-test: + type: boolean + required: false + default: false concurrency: group: e2e-${{ github.head_ref || github.sha }} @@ -66,7 +70,7 @@ jobs: PERFORMANCE_TESTS: ${{ steps.matrix-conditionals.outputs.PERFORMANCE_TESTS }} STATEFUL_DATA_TESTS: ${{ steps.matrix-conditionals.outputs.STATEFUL_DATA_TESTS }} TSS_MIGRATION_TESTS: ${{ steps.matrix-conditionals.outputs.TSS_MIGRATION_TESTS }} - + SOLANA_TESTS: ${{ steps.matrix-conditionals.outputs.SOLANA_TESTS }} steps: # use api rather than event context to avoid race conditions (label added after push) - id: matrix-conditionals @@ -89,6 +93,7 @@ jobs: core.setOutput('PERFORMANCE_TESTS', labels.includes('PERFORMANCE_TESTS')); core.setOutput('STATEFUL_DATA_TESTS', labels.includes('STATEFUL_DATA_TESTS')); core.setOutput('TSS_MIGRATION_TESTS', labels.includes('TSS_MIGRATION_TESTS')); + core.setOutput('SOLANA_TESTS', labels.includes('SOLANA_TESTS')); } else if (context.eventName === 'merge_group') { core.setOutput('DEFAULT_TESTS', true); } else if (context.eventName === 'push' && context.ref === 'refs/heads/develop') { @@ -109,6 +114,7 @@ jobs: core.setOutput('ADMIN_TESTS', true); core.setOutput('PERFORMANCE_TESTS', true); core.setOutput('STATEFUL_DATA_TESTS', true); + core.setOutput('SOLANA_TESTS', true); } else if (context.eventName === 'workflow_dispatch') { core.setOutput('DEFAULT_TESTS', context.payload.inputs['default-test']); core.setOutput('UPGRADE_TESTS', context.payload.inputs['upgrade-test']); @@ -118,6 +124,7 @@ jobs: core.setOutput('PERFORMANCE_TESTS', context.payload.inputs['performance-test']); core.setOutput('STATEFUL_DATA_TESTS', context.payload.inputs['stateful-data-test']); core.setOutput('TSS_MIGRATION_TESTS', context.payload.inputs['tss-migration-test']); + core.setOutput('SOLANA_TESTS', context.payload.inputs['solana-test']); } e2e: @@ -150,6 +157,9 @@ jobs: - make-target: "start-tss-migration-test" runs-on: ubuntu-20.04 run: ${{ needs.matrix-conditionals.outputs.TSS_MIGRATION_TESTS == 'true' }} + - make-target: "start-solana-test" + runs-on: ubuntu-20.04 + run: ${{ needs.matrix-conditionals.outputs.SOLANA_TESTS == 'true' }} name: ${{ matrix.make-target }} uses: ./.github/workflows/reusable-e2e.yml with: diff --git a/Makefile b/Makefile index 93a11ccdd7..bd338deb61 100644 --- a/Makefile +++ b/Makefile @@ -263,6 +263,11 @@ start-tss-migration-test: zetanode export E2E_ARGS="--test-tss-migration" && \ cd contrib/localnet/ && $(DOCKER) compose up -d +start-solana-test: zetanode solana + @echo "--> Starting solana test" + export E2E_ARGS="--skip-regular --test-solana" && \ + cd contrib/localnet/ && $(DOCKER) compose --profile solana -f docker-compose.yml up -d + ############################################################################### ### Upgrade Tests ### ############################################################################### diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 34f3c69540..887a21d43a 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -32,6 +32,7 @@ const ( flagTestAdmin = "test-admin" flagTestPerformance = "test-performance" flagTestCustom = "test-custom" + flagTestSolana = "test-solana" flagSkipRegular = "skip-regular" flagLight = "light" flagSetupOnly = "setup-only" @@ -62,6 +63,7 @@ func NewLocalCmd() *cobra.Command { cmd.Flags().Bool(flagTestAdmin, false, "set to true to run admin tests") cmd.Flags().Bool(flagTestPerformance, false, "set to true to run performance tests") cmd.Flags().Bool(flagTestCustom, false, "set to true to run custom tests") + cmd.Flags().Bool(flagTestSolana, false, "set to true to run solana tests") cmd.Flags().Bool(flagSkipRegular, false, "set to true to skip regular tests") cmd.Flags().Bool(flagLight, false, "run the most basic regular tests, useful for quick checks") cmd.Flags().Bool(flagSetupOnly, false, "set to true to only setup the networks") @@ -84,6 +86,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { testAdmin = must(cmd.Flags().GetBool(flagTestAdmin)) testPerformance = must(cmd.Flags().GetBool(flagTestPerformance)) testCustom = must(cmd.Flags().GetBool(flagTestCustom)) + testSolana = must(cmd.Flags().GetBool(flagTestSolana)) skipRegular = must(cmd.Flags().GetBool(flagSkipRegular)) light = must(cmd.Flags().GetBool(flagLight)) setupOnly = must(cmd.Flags().GetBool(flagSetupOnly)) @@ -176,7 +179,9 @@ func localE2ETest(cmd *cobra.Command, _ []string) { deployerRunner.SetupEVM(contractsDeployed, true) deployerRunner.SetZEVMContracts() - deployerRunner.SetSolanaContracts(conf.AdditionalAccounts.UserSolana.SolanaPrivateKey.String()) + if testSolana { + deployerRunner.SetSolanaContracts(conf.AdditionalAccounts.UserSolana.SolanaPrivateKey.String()) + } noError(deployerRunner.FundEmissionsPool()) deployerRunner.MintERC20OnEvm(10000) @@ -239,9 +244,6 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestMessagePassingZEVMtoEVMRevertFailName, e2etests.TestMessagePassingEVMtoZEVMRevertFailName, } - solanaTests := []string{ - e2etests.TestSolanaDepositName, - } bitcoinTests := []string{ e2etests.TestBitcoinDepositName, @@ -284,7 +286,6 @@ 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, solanaTests...)) eg.Go(ethereumTestRoutine(conf, deployerRunner, verbose, testHeader, ethereumTests...)) } @@ -310,6 +311,9 @@ func localE2ETest(cmd *cobra.Command, _ []string) { if testCustom { eg.Go(miscTestRoutine(conf, deployerRunner, verbose, e2etests.TestMyTestName)) } + if testSolana { + eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, e2etests.TestSolanaDepositName)) + } // while tests are executed, monitor blocks in parallel to check if system txs are on top and they have biggest priority txPriorityErrCh := make(chan error, 1) diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index c182cf3e84..928a9fe5a7 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -227,6 +227,9 @@ services: image: solana-local:latest container_name: solana hostname: solana + profiles: + - solana + - all ports: - "8899:8899" networks: From 492dcc4b4b92c3d1fc0fb5f68855ad28800cae0c Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 17 Jul 2024 18:25:47 -0500 Subject: [PATCH 30/37] filter at most two events [SOL + SPL] per solana tx to be consistent with EVM chain inbound observation --- zetaclient/chains/solana/observer/inbound.go | 85 ++++++++++++++----- .../chains/solana/observer/inbound_test.go | 11 +-- .../chains/solana/observer/inbound_tracker.go | 4 +- 3 files changed, 71 insertions(+), 29 deletions(-) diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index f78c18aa4f..2abc736ca4 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -107,8 +107,8 @@ func (ob *Observer) ObserveInbound(ctx context.Context) error { return errors.Wrapf(err, "error GetTransaction for chain %d sig %s", chainID, sigString) } - // filter inbound event and vote - err = ob.FilterInboundEventAndVote(ctx, txResult) + // filter inbound events and vote + err = ob.FilterInboundEventsAndVote(ctx, txResult) if err != nil { // we have to re-scan this signature on next ticker return errors.Wrapf(err, "error FilterInboundEventAndVote for chain %d sig %s", chainID, sigString) @@ -135,16 +135,16 @@ func (ob *Observer) ObserveInbound(ctx context.Context) error { return nil } -// FilterInboundEventAndVote filters inbound event from a txResult and post a vote. -func (ob *Observer) FilterInboundEventAndVote(ctx context.Context, txResult *rpc.GetTransactionResult) error { - // filter one single inbound event from txResult - event, err := ob.FilterInboundEvent(txResult) +// FilterInboundEventsAndVote filters inbound events from a txResult and post a vote. +func (ob *Observer) FilterInboundEventsAndVote(ctx context.Context, txResult *rpc.GetTransactionResult) error { + // filter inbound events from txResult + events, err := ob.FilterInboundEvents(txResult) if err != nil { return errors.Wrapf(err, "error FilterInboundEvent") } - // build inbound vote message from event and post to zetacore - if event != nil { + // build inbound vote message from events and post to zetacore + for _, event := range events { msg := ob.BuildInboundVoteMsgFromEvent(event) if msg != nil { _, err = ob.PostVoteInbound(ctx, msg, zetacore.PostVoteInboundExecutionGasLimit) @@ -157,9 +157,12 @@ func (ob *Observer) FilterInboundEventAndVote(ctx context.Context, txResult *rpc 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) { +// FilterInboundEvents filters inbound events from a tx result. +// Note: for consistency with EVM chains, this method +// - takes at one event (the first) per token (SOL or SPL) per transaction. +// - takes at most two events (one SOL + one SPL) per transaction. +// - ignores exceeding events. +func (ob *Observer) FilterInboundEvents(txResult *rpc.GetTransactionResult) ([]*clienttypes.InboundEvent, error) { // unmarshal transaction tx, err := txResult.Transaction.GetTransaction() if err != nil { @@ -171,6 +174,11 @@ func (ob *Observer) FilterInboundEvent(txResult *rpc.GetTransactionResult) (*cli return nil, nil } + // create event array to collect all events in the transaction + seenDeposit := false + seenDepositSPL := false + events := make([]*clienttypes.InboundEvent, 0) + // loop through instruction list to filter the 1st valid event for i, instruction := range tx.Message.Instructions { // get the program ID @@ -187,18 +195,40 @@ func (ob *Observer) FilterInboundEvent(txResult *rpc.GetTransactionResult) (*cli 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") + // try parsing the instruction as a 'deposit' if not seen yet + if !seenDeposit { + event, err := ob.ParseInboundAsDeposit(tx, i, txResult.Slot) + if err != nil { + return nil, errors.Wrap(err, "error ParseInboundAsDeposit") + } else if event != nil { + seenDeposit = true + events = append(events, event) + ob.Logger().Inbound.Info(). + Msgf("FilterInboundEvents: deposit detected in sig %s instruction %d", tx.Signatures[0], i) + } + } else { + ob.Logger().Inbound.Warn(). + Msgf("FilterInboundEvents: multiple deposits detected in sig %s instruction %d", tx.Signatures[0], i) } - // TODO: try parsing the instruction as 'deposit_spl_token' - return event, nil + // try parsing the instruction as a 'deposit_spl_token' if not seen yet + if !seenDepositSPL { + event, err := ob.ParseInboundAsDepositSPL(tx, i, txResult.Slot) + if err != nil { + return nil, errors.Wrap(err, "error ParseInboundAsDepositSPL") + } else if event != nil { + seenDepositSPL = true + events = append(events, event) + ob.Logger().Inbound.Info(). + Msgf("FilterInboundEvents: SPL deposit detected in sig %s instruction %d", tx.Signatures[0], i) + } + } else { + ob.Logger().Inbound.Warn(). + Msgf("FilterInboundEvents: multiple SPL deposits detected in sig %s instruction %d", tx.Signatures[0], i) + } } - // no event found for this signature - return nil, nil + return events, nil } // BuildInboundVoteMsgFromEvent builds a MsgVoteInbound from an inbound event @@ -233,8 +263,8 @@ func (ob *Observer) BuildInboundVoteMsgFromEvent(event *clienttypes.InboundEvent ) } -// ParseInboundAsDeposit tries to parse an instruction as a deposit. -// It returns nil if the instruction can't be parsed as a deposit. +// 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, @@ -269,7 +299,7 @@ func (ob *Observer) ParseInboundAsDeposit( TxOrigin: sender, Amount: inst.Amount, Memo: inst.Memo, - BlockNumber: slot, // instead of using block, we use slot for Solana for better indexing + BlockNumber: slot, // instead of using block, Solana explorer uses slot for indexing TxHash: tx.Signatures[0].String(), Index: 0, // hardcode to 0 for Solana, not a EVM smart contract call CoinType: coin.CoinType_Gas, @@ -279,6 +309,17 @@ func (ob *Observer) ParseInboundAsDeposit( return event, nil } +// ParseInboundAsDepositSPL tries to parse an instruction as a 'deposit_spl_token'. +// It returns nil if the instruction can't be parsed as a 'deposit_spl_token'. +func (ob *Observer) ParseInboundAsDepositSPL( + _ *solana.Transaction, + _ int, + _ uint64, +) (*clienttypes.InboundEvent, error) { + // not implemented yet + return nil, nil +} + // GetSignerDeposit returns the signer address of the deposit instruction // Note: solana-go is not able to parse the AccountMeta 'is_signer' ATM. This is a workaround. func (ob *Observer) GetSignerDeposit(tx *solana.Transaction, inst *solana.CompiledInstruction) (string, error) { diff --git a/zetaclient/chains/solana/observer/inbound_test.go b/zetaclient/chains/solana/observer/inbound_test.go index cedc52f86a..2b3692ad62 100644 --- a/zetaclient/chains/solana/observer/inbound_test.go +++ b/zetaclient/chains/solana/observer/inbound_test.go @@ -40,13 +40,13 @@ func Test_FilterInboundEventAndVote(t *testing.T) { ob, err := observer.NewObserver(chain, nil, *chainParams, zetacoreClient, nil, dbpath, base.DefaultLogger(), nil) require.NoError(t, err) - t.Run("should filter inbound event vote", func(t *testing.T) { - err := ob.FilterInboundEventAndVote(context.TODO(), txResult) + t.Run("should filter inbound events and vote", func(t *testing.T) { + err := ob.FilterInboundEventsAndVote(context.TODO(), txResult) require.NoError(t, err) }) } -func Test_FilterInboundEvent(t *testing.T) { +func Test_FilterInboundEvents(t *testing.T) { // load archived inbound deposit tx result // https://explorer.solana.com/tx/5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk?cluster=devnet txHash := "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" @@ -77,11 +77,12 @@ func Test_FilterInboundEvent(t *testing.T) { } t.Run("should filter inbound event deposit SOL", func(t *testing.T) { - event, err := ob.FilterInboundEvent(txResult) + events, err := ob.FilterInboundEvents(txResult) require.NoError(t, err) // check result - require.EqualValues(t, eventExpected, event) + require.Len(t, events, 1) + require.EqualValues(t, eventExpected, events[0]) }) } diff --git a/zetaclient/chains/solana/observer/inbound_tracker.go b/zetaclient/chains/solana/observer/inbound_tracker.go index e39cefeb5a..bcc9c83c15 100644 --- a/zetaclient/chains/solana/observer/inbound_tracker.go +++ b/zetaclient/chains/solana/observer/inbound_tracker.go @@ -68,8 +68,8 @@ func (ob *Observer) ProcessInboundTrackers(ctx context.Context) error { return errors.Wrapf(err, "error GetTransaction for chain %d sig %s", chainID, signature) } - // filter inbound event and vote - err = ob.FilterInboundEventAndVote(ctx, txResult) + // filter inbound events and vote + err = ob.FilterInboundEventsAndVote(ctx, txResult) if err != nil { return errors.Wrapf(err, "error FilterInboundEventAndVote for chain %d sig %s", chainID, signature) } From 31d09e3ed0c317b13c76204074c9c70ffad1dba0 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 17 Jul 2024 18:39:25 -0500 Subject: [PATCH 31/37] fix unit test compile --- zetaclient/chains/base/observer_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/zetaclient/chains/base/observer_test.go b/zetaclient/chains/base/observer_test.go index 42ab65882c..cd3ce26374 100644 --- a/zetaclient/chains/base/observer_test.go +++ b/zetaclient/chains/base/observer_test.go @@ -478,12 +478,14 @@ func TestSaveLastTxScanned(t *testing.T) { require.NoError(t, err) // save random tx hash + lastSlot := uint64(100) lastTx := "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" - err = ob.SaveLastTxScanned(lastTx) + err = ob.SaveLastTxScanned(lastTx, lastSlot) require.NoError(t, err) - // check last tx scanned in memory + // check last tx and slot scanned in memory require.EqualValues(t, lastTx, ob.LastTxScanned()) + require.EqualValues(t, lastSlot, ob.LastBlockScanned()) // read last tx scanned from db lastTxScanned, err := ob.ReadLastTxScannedFromDB() From 919260b78fba3d5a2bfdea2c45fb9c30e20fb875 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Thu, 18 Jul 2024 09:59:39 -0500 Subject: [PATCH 32/37] use observer context for Solana RPC calls --- zetaclient/chains/solana/observer/inbound.go | 2 +- zetaclient/chains/solana/observer/inbound_tracker.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index 2abc736ca4..0e63e045ee 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -101,7 +101,7 @@ func (ob *Observer) ObserveInbound(ctx context.Context) error { // process successfully signature only if sig.Err == nil { - txResult, err := ob.solClient.GetTransaction(context.TODO(), sig.Signature, &rpc.GetTransactionOpts{}) + txResult, err := ob.solClient.GetTransaction(ctx, sig.Signature, &rpc.GetTransactionOpts{}) if err != nil { // we have to re-scan this signature on next ticker return errors.Wrapf(err, "error GetTransaction for chain %d sig %s", chainID, sigString) diff --git a/zetaclient/chains/solana/observer/inbound_tracker.go b/zetaclient/chains/solana/observer/inbound_tracker.go index bcc9c83c15..7665359949 100644 --- a/zetaclient/chains/solana/observer/inbound_tracker.go +++ b/zetaclient/chains/solana/observer/inbound_tracker.go @@ -61,7 +61,7 @@ func (ob *Observer) ProcessInboundTrackers(ctx context.Context) error { // process inbound trackers for _, tracker := range trackers { signature := solana.MustSignatureFromBase58(tracker.TxHash) - txResult, err := ob.solClient.GetTransaction(context.TODO(), signature, &rpc.GetTransactionOpts{ + txResult, err := ob.solClient.GetTransaction(ctx, signature, &rpc.GetTransactionOpts{ Commitment: rpc.CommitmentFinalized, }) if err != nil { From 36435d0f22541fecc64a61639d05757f8e6a7477 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Thu, 18 Jul 2024 13:13:19 -0500 Subject: [PATCH 33/37] better format stack information on panic --- pkg/bg/bg.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/bg/bg.go b/pkg/bg/bg.go index 43c77ae27c..4e408ef6f1 100644 --- a/pkg/bg/bg.go +++ b/pkg/bg/bg.go @@ -40,7 +40,7 @@ func Work(ctx context.Context, f func(context.Context) error, opts ...Opt) { if r := recover(); r != nil { err := fmt.Errorf("recovered from PANIC in background task: %v", r) logError(err, cfg) - printStack() + printStack(err, cfg) } }() @@ -64,7 +64,7 @@ func logError(err error, cfg config) { } // printStack prints the stack trace when a panic occurs -func printStack() { +func printStack(err error, cfg config) { buf := make([]byte, 1024) for { n := runtime.Stack(buf, false) @@ -74,5 +74,12 @@ func printStack() { } buf = make([]byte, 2*len(buf)) } - fmt.Printf("Stack trace:\n%s\n", string(buf)) + stack := string(buf) + + name := cfg.name + if name == "" { + name = "unknown" + } + + cfg.logger.Error().Err(err).Str("worker.name", name).Interface("stack", stack).Msgf("Background task failed") } From f199256494cbb920ae8f242be7b0587363eb9d1e Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Thu, 18 Jul 2024 13:22:18 -0500 Subject: [PATCH 34/37] move stack print into logError() to avoid duplicate log print --- pkg/bg/bg.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/pkg/bg/bg.go b/pkg/bg/bg.go index 4e408ef6f1..707236839e 100644 --- a/pkg/bg/bg.go +++ b/pkg/bg/bg.go @@ -40,7 +40,6 @@ func Work(ctx context.Context, f func(context.Context) error, opts ...Opt) { if r := recover(); r != nil { err := fmt.Errorf("recovered from PANIC in background task: %v", r) logError(err, cfg) - printStack(err, cfg) } }() @@ -55,16 +54,7 @@ func logError(err error, cfg config) { return } - name := cfg.name - if name == "" { - name = "unknown" - } - - cfg.logger.Error().Err(err).Str("worker.name", name).Msgf("Background task failed") -} - -// printStack prints the stack trace when a panic occurs -func printStack(err error, cfg config) { + // print stack trace when a panic occurs buf := make([]byte, 1024) for { n := runtime.Stack(buf, false) From da593ce7516bcdc52627cc5dd2a5c3afc09ccaf0 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 18 Jul 2024 20:28:45 +0200 Subject: [PATCH 35/37] Fix `bg` --- pkg/bg/bg.go | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/pkg/bg/bg.go b/pkg/bg/bg.go index 707236839e..3fd6a74fc3 100644 --- a/pkg/bg/bg.go +++ b/pkg/bg/bg.go @@ -39,37 +39,42 @@ func Work(ctx context.Context, f func(context.Context) error, opts ...Opt) { defer func() { if r := recover(); r != nil { err := fmt.Errorf("recovered from PANIC in background task: %v", r) - logError(err, cfg) + logError(err, cfg, true) } }() if err := f(ctx); err != nil { - logError(err, cfg) + logError(err, cfg, false) } }() } -func logError(err error, cfg config) { +func logError(err error, cfg config, isPanic bool) { if err == nil { return } + evt := cfg.logger.Error().Err(err) + // print stack trace when a panic occurs - buf := make([]byte, 1024) - for { - n := runtime.Stack(buf, false) - if n < len(buf) { - buf = buf[:n] - break + if isPanic { + buf := make([]byte, 1024) + for { + n := runtime.Stack(buf, false) + if n < len(buf) { + buf = buf[:n] + break + } + buf = make([]byte, 2*len(buf)) } - buf = make([]byte, 2*len(buf)) + + evt.Bytes("stack_trace", buf) } - stack := string(buf) name := cfg.name if name == "" { name = "unknown" } - cfg.logger.Error().Err(err).Str("worker.name", name).Interface("stack", stack).Msgf("Background task failed") + evt.Str("worker.name", name).Msg("Background task failed") } From 966b56bc1250e9d899a8b7701420f6e8bcba38b1 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Thu, 18 Jul 2024 14:02:47 -0500 Subject: [PATCH 36/37] fix unit test --- pkg/bg/bg_test.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/bg/bg_test.go b/pkg/bg/bg_test.go index c55b6287f9..6bbcffd003 100644 --- a/pkg/bg/bg_test.go +++ b/pkg/bg/bg_test.go @@ -9,6 +9,7 @@ import ( "github.com/rs/zerolog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestWork(t *testing.T) { @@ -69,9 +70,12 @@ func TestWork(t *testing.T) { time.Sleep(100 * time.Millisecond) // Check the log output - const expected = `{"level":"error","error":"recovered from PANIC in background task: press F",` + - `"worker.name":"unknown","message":"Background task failed"}` - assert.JSONEq(t, expected, out.String()) + const expectedError = "recovered from PANIC in background task: press F" + const expectedWorker = "unknown" + const expectedMessage = "Background task failed" + require.Contains(t, out.String(), expectedError) + require.Contains(t, out.String(), expectedWorker) + require.Contains(t, out.String(), expectedMessage) }) } From b15c0808d74cbd7f6adc85d79feaba2185aa970e Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Fri, 19 Jul 2024 10:46:45 -0500 Subject: [PATCH 37/37] rename pdaID as pda to be a more correct Solana terminology --- zetaclient/chains/solana/observer/inbound.go | 2 +- zetaclient/chains/solana/observer/observer.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index 0e63e045ee..4a819ce9cb 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -338,7 +338,7 @@ func (ob *Observer) GetSignerDeposit(tx *solana.Transaction, inst *solana.Compil accKey := tx.Message.AccountKeys[accIndexInt] switch accKey { - case ob.pdaID: + case ob.pda: pdaIndex = accIndexInt case ob.gatewayID: gatewayIndex = accIndexInt diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index aa8950ae50..ec818732ca 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -28,7 +28,7 @@ type Observer struct { gatewayID solana.PublicKey // pda is the program derived address of the gateway program - pdaID solana.PublicKey + pda solana.PublicKey } // NewObserver returns a new Solana chain observer @@ -66,7 +66,7 @@ func NewObserver( // compute gateway PDA seed := []byte(solanacontract.PDASeed) - ob.pdaID, _, err = solana.FindProgramAddress([][]byte{seed}, ob.gatewayID) + ob.pda, _, err = solana.FindProgramAddress([][]byte{seed}, ob.gatewayID) if err != nil { return nil, err }