Node/CCQ: Solana query support (#3637)

* Node/CCQ: Solana query support

* Add mock stuff

* Add mock stuff

* Code review rework

* Code review rework

* Only allow "finalized", not "confirmed"

* Code review rework

* Change SolanaAccount query type to 4

* Code review rework

* Fix sdk tests
This commit is contained in:
bruce-riley 2024-01-22 18:59:02 -06:00 committed by GitHub
parent 7acbacd0ea
commit 59dff67821
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1728 additions and 109 deletions

View File

@ -132,6 +132,20 @@
"contractAddress": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
"call": "0x313ce567"
}
},
{
"solAccount": {
"note:": "Example token on Devnet",
"chain": 1,
"account": "2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ"
}
},
{
"solAccount": {
"note:": "Example NFT on Devnet",
"chain": 1,
"account": "BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna"
}
}
]
},

View File

@ -157,7 +157,7 @@ func TestParseConfigUnsupportedCallType(t *testing.T) {
_, err := parseConfig([]byte(str))
require.Error(t, err)
assert.Equal(t, `unsupported call type for user "Test User", must be "ethCall", "ethCallByTimestamp" or "ethCallWithFinality"`, err.Error())
assert.Equal(t, `unsupported call type for user "Test User", must be "ethCall", "ethCallByTimestamp", "ethCallWithFinality" or "solAccount"`, err.Error())
}
func TestParseConfigInvalidContractAddress(t *testing.T) {

View File

@ -11,9 +11,11 @@ import (
"sync"
"github.com/certusone/wormhole/node/pkg/common"
"github.com/certusone/wormhole/node/pkg/query"
"github.com/wormhole-foundation/wormhole/sdk/vaa"
"go.uber.org/zap"
"github.com/gagliardetto/solana-go"
"gopkg.in/godo.v2/watcher/fswatch"
)
@ -33,6 +35,7 @@ type (
EthCall *EthCall `json:"ethCall"`
EthCallByTimestamp *EthCallByTimestamp `json:"ethCallByTimestamp"`
EthCallWithFinality *EthCallWithFinality `json:"ethCallWithFinality"`
SolanaAccount *SolanaAccount `json:"solAccount"`
}
EthCall struct {
@ -53,6 +56,11 @@ type (
Call string `json:"call"`
}
SolanaAccount struct {
Chain int `json:"chain"`
Account string `json:"account"`
}
PermissionsMap map[string]*permissionEntry
permissionEntry struct {
@ -187,7 +195,7 @@ func parseConfig(byteValue []byte) (PermissionsMap, error) {
allowedCalls := make(allowedCallsForUser)
for _, ac := range user.AllowedCalls {
var chain int
var callType, contractAddressStr, callStr string
var callType, contractAddressStr, callStr, callKey string
// var contractAddressStr string
if ac.EthCall != nil {
callType = "ethCall"
@ -204,27 +212,49 @@ func parseConfig(byteValue []byte) (PermissionsMap, error) {
chain = ac.EthCallWithFinality.Chain
contractAddressStr = ac.EthCallWithFinality.ContractAddress
callStr = ac.EthCallWithFinality.Call
} else if ac.SolanaAccount != nil {
// We assume the account is base58, but if it starts with "0x" it should be 32 bytes of hex.
account := ac.SolanaAccount.Account
if strings.HasPrefix(account, "0x") {
buf, err := hex.DecodeString(account[2:])
if err != nil {
return nil, fmt.Errorf(`invalid solana account hex string "%s" for user "%s": %w`, account, user.UserName, err)
}
if len(buf) != query.SolanaPublicKeyLength {
return nil, fmt.Errorf(`invalid solana account hex string "%s" for user "%s, must be %d bytes`, account, user.UserName, query.SolanaPublicKeyLength)
}
account = solana.PublicKey(buf).String()
} else {
// Make sure it is valid base58.
_, err := solana.PublicKeyFromBase58(account)
if err != nil {
return nil, fmt.Errorf(`solana account string "%s" for user "%s" is not valid base58: %w`, account, user.UserName, err)
}
}
callKey = fmt.Sprintf("solAccount:%d:%s", ac.SolanaAccount.Chain, account)
} else {
return nil, fmt.Errorf(`unsupported call type for user "%s", must be "ethCall", "ethCallByTimestamp" or "ethCallWithFinality"`, user.UserName)
return nil, fmt.Errorf(`unsupported call type for user "%s", must be "ethCall", "ethCallByTimestamp", "ethCallWithFinality" or "solAccount"`, user.UserName)
}
// Convert the contract address into a standard format like "000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6".
contractAddress, err := vaa.StringToAddress(contractAddressStr)
if err != nil {
return nil, fmt.Errorf(`invalid contract address "%s" for user "%s"`, contractAddressStr, user.UserName)
}
if callKey == "" {
// Convert the contract address into a standard format like "000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6".
contractAddress, err := vaa.StringToAddress(contractAddressStr)
if err != nil {
return nil, fmt.Errorf(`invalid contract address "%s" for user "%s"`, contractAddressStr, user.UserName)
}
// The call should be the ABI four byte hex hash of the function signature. Parse it into a standard form of "06fdde03".
call, err := hex.DecodeString(strings.TrimPrefix(callStr, "0x"))
if err != nil {
return nil, fmt.Errorf(`invalid eth call "%s" for user "%s"`, callStr, user.UserName)
}
if len(call) != ETH_CALL_SIG_LENGTH {
return nil, fmt.Errorf(`eth call "%s" for user "%s" has an invalid length, must be %d bytes`, callStr, user.UserName, ETH_CALL_SIG_LENGTH)
}
// The call should be the ABI four byte hex hash of the function signature. Parse it into a standard form of "06fdde03".
call, err := hex.DecodeString(strings.TrimPrefix(callStr, "0x"))
if err != nil {
return nil, fmt.Errorf(`invalid eth call "%s" for user "%s"`, callStr, user.UserName)
}
if len(call) != ETH_CALL_SIG_LENGTH {
return nil, fmt.Errorf(`eth call "%s" for user "%s" has an invalid length, must be %d bytes`, callStr, user.UserName, ETH_CALL_SIG_LENGTH)
}
// The permission key is the chain, contract address and call formatted as a colon separated string.
callKey := fmt.Sprintf("%s:%d:%s:%s", callType, chain, contractAddress, hex.EncodeToString(call))
// The permission key is the chain, contract address and call formatted as a colon separated string.
callKey = fmt.Sprintf("%s:%d:%s:%s", callType, chain, contractAddress, hex.EncodeToString(call))
}
if _, exists := allowedCalls[callKey]; exists {
return nil, fmt.Errorf(`"%s" is a duplicate allowed call for user "%s"`, callKey, user.UserName)

View File

@ -20,6 +20,8 @@ import (
ethCrypto "github.com/ethereum/go-ethereum/crypto"
ethClient "github.com/ethereum/go-ethereum/ethclient"
ethRpc "github.com/ethereum/go-ethereum/rpc"
"github.com/gagliardetto/solana-go"
)
func FetchCurrentGuardianSet(rpcUrl, coreAddr string) (*common.GuardianSet, error) {
@ -87,7 +89,7 @@ func validateRequest(logger *zap.Logger, env common.Environment, perms *Permissi
if err != nil {
logger.Debug("failed to unmarshal request", zap.String("userName", permsForUser.userName), zap.Error(err))
invalidQueryRequestReceived.WithLabelValues("failed_to_unmarshal_request").Inc()
return http.StatusInternalServerError, fmt.Errorf("failed to unmarshal request: %w", err)
return http.StatusBadRequest, fmt.Errorf("failed to unmarshal request: %w", err)
}
// Make sure the overall query request is sane.
@ -108,6 +110,8 @@ func validateRequest(logger *zap.Logger, env common.Environment, perms *Permissi
status, err = validateCallData(logger, permsForUser, "ethCallByTimestamp", pcq.ChainId, q.CallData)
case *query.EthCallWithFinalityQueryRequest:
status, err = validateCallData(logger, permsForUser, "ethCallWithFinality", pcq.ChainId, q.CallData)
case *query.SolanaAccountQueryRequest:
status, err = validateSolanaAccountQuery(logger, permsForUser, "solAccount", pcq.ChainId, q)
default:
logger.Debug("unsupported query type", zap.String("userName", permsForUser.userName), zap.Any("type", pcq.Query))
invalidQueryRequestReceived.WithLabelValues("unsupported_query_type").Inc()
@ -151,3 +155,19 @@ func validateCallData(logger *zap.Logger, permsForUser *permissionEntry, callTag
return http.StatusOK, nil
}
// validateSolanaAccountQuery performs verification on a Solana sol_account query.
func validateSolanaAccountQuery(logger *zap.Logger, permsForUser *permissionEntry, callTag string, chainId vaa.ChainID, q *query.SolanaAccountQueryRequest) (int, error) {
for _, acct := range q.Accounts {
callKey := fmt.Sprintf("%s:%d:%s", callTag, chainId, solana.PublicKey(acct).String())
if _, exists := permsForUser.allowedCalls[callKey]; !exists {
logger.Debug("requested call not authorized", zap.String("userName", permsForUser.userName), zap.String("callKey", callKey))
invalidQueryRequestReceived.WithLabelValues("call_not_authorized").Inc()
return http.StatusBadRequest, fmt.Errorf(`call "%s" not authorized`, callKey)
}
totalRequestedCallsByChain.WithLabelValues(chainId.String()).Inc()
}
return http.StatusOK, nil
}

View File

@ -26,6 +26,8 @@ import (
"github.com/tendermint/tendermint/libs/rand"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"github.com/gagliardetto/solana-go"
)
// this script has to be run inside kubernetes since it relies on UDP
@ -117,6 +119,40 @@ func main() {
// END SETUP
//
//
// Solana Tests
//
{
logger.Info("Running Solana tests")
// Start of query creation...
account1, err := solana.PublicKeyFromBase58("Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o")
if err != nil {
panic("solana account1 is invalid")
}
account2, err := solana.PublicKeyFromBase58("B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE")
if err != nil {
panic("solana account2 is invalid")
}
callRequest := &query.SolanaAccountQueryRequest{
Commitment: "finalized",
DataSliceOffset: 0,
DataSliceLength: 100,
Accounts: [][query.SolanaPublicKeyLength]byte{account1, account2},
}
queryRequest := createSolanaQueryRequest(callRequest)
sendSolanaQueryAndGetRsp(queryRequest, sk, th_req, ctx, logger, sub)
logger.Info("Solana tests complete!")
}
// return
//
// EVM Tests
//
wethAbi, err := abi.JSON(strings.NewReader("[{\"constant\":true,\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"name\":\"\",\"type\":\"string\"}],\"payable\":false,\"stateMutability\":\"view\",\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"payable\":false,\"stateMutability\":\"view\",\"type\":\"function\"}]"))
if err != nil {
panic(err)
@ -355,3 +391,108 @@ func sendQueryAndGetRsp(queryRequest *query.QueryRequest, sk *ecdsa.PrivateKey,
}
}
}
func createSolanaQueryRequest(callRequest *query.SolanaAccountQueryRequest) *query.QueryRequest {
queryRequest := &query.QueryRequest{
Nonce: rand.Uint32(),
PerChainQueries: []*query.PerChainQueryRequest{
{
ChainId: 1,
Query: callRequest,
},
},
}
return queryRequest
}
func sendSolanaQueryAndGetRsp(queryRequest *query.QueryRequest, sk *ecdsa.PrivateKey, th *pubsub.Topic, ctx context.Context, logger *zap.Logger, sub *pubsub.Subscription) {
queryRequestBytes, err := queryRequest.Marshal()
if err != nil {
panic(err)
}
numQueries := len(queryRequest.PerChainQueries)
// Sign the query request using our private key.
digest := query.QueryRequestDigest(common.UnsafeDevNet, queryRequestBytes)
sig, err := ethCrypto.Sign(digest.Bytes(), sk)
if err != nil {
panic(err)
}
signedQueryRequest := &gossipv1.SignedQueryRequest{
QueryRequest: queryRequestBytes,
Signature: sig,
}
msg := gossipv1.GossipMessage{
Message: &gossipv1.GossipMessage_SignedQueryRequest{
SignedQueryRequest: signedQueryRequest,
},
}
b, err := proto.Marshal(&msg)
if err != nil {
panic(err)
}
err = th.Publish(ctx, b)
if err != nil {
panic(err)
}
logger.Info("Waiting for message...")
// TODO: max wait time
// TODO: accumulate signatures to reach quorum
for {
envelope, err := sub.Next(ctx)
if err != nil {
logger.Panic("failed to receive pubsub message", zap.Error(err))
}
var msg gossipv1.GossipMessage
err = proto.Unmarshal(envelope.Data, &msg)
if err != nil {
logger.Info("received invalid message",
zap.Binary("data", envelope.Data),
zap.String("from", envelope.GetFrom().String()))
continue
}
var isMatchingResponse bool
switch m := msg.Message.(type) {
case *gossipv1.GossipMessage_SignedQueryResponse:
logger.Info("query response received", zap.Any("response", m.SignedQueryResponse),
zap.String("responseBytes", hexutil.Encode(m.SignedQueryResponse.QueryResponse)),
zap.String("sigBytes", hexutil.Encode(m.SignedQueryResponse.Signature)))
isMatchingResponse = true
var response query.QueryResponsePublication
err := response.Unmarshal(m.SignedQueryResponse.QueryResponse)
if err != nil {
logger.Warn("failed to unmarshal response", zap.Error(err))
break
}
if bytes.Equal(response.Request.QueryRequest, queryRequestBytes) && bytes.Equal(response.Request.Signature, sig) {
// TODO: verify response signature
isMatchingResponse = true
if len(response.PerChainResponses) != numQueries {
logger.Warn("unexpected number of per chain query responses", zap.Int("expectedNum", numQueries), zap.Int("actualNum", len(response.PerChainResponses)))
break
}
// Do double loop over responses
for index := range response.PerChainResponses {
switch r := response.PerChainResponses[index].Response.(type) {
case *query.SolanaAccountQueryResponse:
logger.Info("solana query per chain response", zap.Int("index", index), zap.Any("pcr", r))
default:
panic(fmt.Sprintf("unsupported query type, should be solana, index: %d", index))
}
}
}
default:
continue
}
if isMatchingResponse {
break
}
}
}

View File

@ -370,11 +370,10 @@ func GuardianOptionWatchers(watcherConfigs []watchers.WatcherConfig, ibcWatcherC
if wc.GetNetworkID() != "solana-confirmed" { // TODO this should not be a special case, see comment in common/readiness.go
common.MustRegisterReadinessSyncing(wc.GetChainID())
chainObsvReqC[wc.GetChainID()] = make(chan *gossipv1.ObservationRequest, observationRequestPerChainBufferSize)
g.chainQueryReqC[wc.GetChainID()] = make(chan *query.PerChainQueryInternal, query.QueryRequestBufferSize)
}
chainObsvReqC[wc.GetChainID()] = make(chan *gossipv1.ObservationRequest, observationRequestPerChainBufferSize)
g.chainQueryReqC[wc.GetChainID()] = make(chan *query.PerChainQueryInternal, query.QueryRequestBufferSize)
if wc.RequiredL1Finalizer() != "" {
l1watcher, ok := watchers[wc.RequiredL1Finalizer()]
if !ok || l1watcher == nil {

View File

@ -126,8 +126,9 @@ func handleQueryRequestsImpl(
pendingQueries := make(map[string]*pendingQuery) // Key is requestID.
// CCQ is currently only supported on EVM.
// CCQ is currently only supported on EVM and Solana.
supportedChains := map[vaa.ChainID]struct{}{
vaa.ChainIDSolana: {},
vaa.ChainIDEthereum: {},
vaa.ChainIDBSC: {},
vaa.ChainIDPolygon: {},

View File

@ -13,6 +13,8 @@ import (
ethCommon "github.com/ethereum/go-ethereum/common"
ethCrypto "github.com/ethereum/go-ethereum/crypto"
solana "github.com/gagliardetto/solana-go"
)
// MSG_VERSION is the current version of the CCQ message protocol.
@ -114,6 +116,45 @@ type EthCallData struct {
const EvmContractAddressLength = 20
////////////////////////////////// Solana Queries ////////////////////////////////////////////////
// SolanaAccountQueryRequestType is the type of a Solana sol_account query request.
const SolanaAccountQueryRequestType ChainSpecificQueryType = 4
// SolanaAccountQueryRequest implements ChainSpecificQuery for an EVM eth_call query request.
type SolanaAccountQueryRequest struct {
// Commitment identifies the commitment level to be used in the queried. Currently it may only "finalized".
// Before we can support "confirmed", we need a way to read the account data and the block information atomically.
Commitment string
// The minimum slot that the request can be evaluated at. Zero means unused.
MinContextSlot uint64
// The offset of the start of data to be returned. Unused if DataSliceLength is zero.
DataSliceOffset uint64
// The length of the data to be returned. Zero means all data is returned.
DataSliceLength uint64
// Accounts is an array of accounts to be queried, in base58 representation.
Accounts [][SolanaPublicKeyLength]byte
}
// Solana public keys are fixed length.
const SolanaPublicKeyLength = solana.PublicKeyLength
// According to the Solana spec, the longest comment string is nine characters. Allow a few more, just in case.
// https://pkg.go.dev/github.com/gagliardetto/solana-go/rpc#CommitmentType
const SolanaMaxCommitmentLength = 12
// According to the spec, the query only supports up to 100 accounts.
// https://github.com/solana-labs/solana/blob/9d132441fdc6282a8be4bff0bc77d6a2fefe8b59/rpc-client-api/src/request.rs#L204
const SolanaMaxAccountsPerQuery = 100
func (saq *SolanaAccountQueryRequest) AccountList() [][SolanaPublicKeyLength]byte {
return saq.Accounts
}
// PerChainQueryInternal is an internal representation of a query request that is passed to the watcher.
type PerChainQueryInternal struct {
RequestID string
@ -216,6 +257,10 @@ func (queryRequest *QueryRequest) UnmarshalFromReader(reader *bytes.Reader) erro
return fmt.Errorf("excess bytes in unmarshal")
}
if err := queryRequest.Validate(); err != nil {
return fmt.Errorf("unmarshaled request failed validation: %w", err)
}
return nil
}
@ -329,6 +374,12 @@ func (perChainQuery *PerChainQueryRequest) UnmarshalFromReader(reader *bytes.Rea
return fmt.Errorf("failed to unmarshal eth call with finality request: %w", err)
}
perChainQuery.Query = &q
case SolanaAccountQueryRequestType:
q := SolanaAccountQueryRequest{}
if err := q.UnmarshalFromReader(reader); err != nil {
return fmt.Errorf("failed to unmarshal solana account query request: %w", err)
}
perChainQuery.Query = &q
default:
return fmt.Errorf("unsupported query type: %d", queryType)
}
@ -376,28 +427,35 @@ func (left *PerChainQueryRequest) Equal(right *PerChainQueryRequest) bool {
return false
}
switch leftEcq := left.Query.(type) {
switch leftQuery := left.Query.(type) {
case *EthCallQueryRequest:
switch rightEcd := right.Query.(type) {
switch rightQuery := right.Query.(type) {
case *EthCallQueryRequest:
return leftEcq.Equal(rightEcd)
return leftQuery.Equal(rightQuery)
default:
panic("unsupported query type on right, must be eth_call")
}
case *EthCallByTimestampQueryRequest:
switch rightEcd := right.Query.(type) {
switch rightQuery := right.Query.(type) {
case *EthCallByTimestampQueryRequest:
return leftEcq.Equal(rightEcd)
return leftQuery.Equal(rightQuery)
default:
panic("unsupported query type on right, must be eth_call_by_timestamp")
}
case *EthCallWithFinalityQueryRequest:
switch rightEcd := right.Query.(type) {
switch rightQuery := right.Query.(type) {
case *EthCallWithFinalityQueryRequest:
return leftEcq.Equal(rightEcd)
return leftQuery.Equal(rightQuery)
default:
panic("unsupported query type on right, must be eth_call_with_finality")
}
case *SolanaAccountQueryRequest:
switch rightQuery := right.Query.(type) {
case *SolanaAccountQueryRequest:
return leftQuery.Equal(rightQuery)
default:
panic("unsupported query type on right, must be sol_account")
}
default:
panic("unsupported query type on left")
}
@ -860,8 +918,140 @@ func (left *EthCallWithFinalityQueryRequest) Equal(right *EthCallWithFinalityQue
return true
}
//
// Implementation of SolanaAccountQueryRequest, which implements the ChainSpecificQuery interface.
//
func (e *SolanaAccountQueryRequest) Type() ChainSpecificQueryType {
return SolanaAccountQueryRequestType
}
// Marshal serializes the binary representation of a Solana sol_account request.
// This method calls Validate() and relies on it to range checks lengths, etc.
func (saq *SolanaAccountQueryRequest) Marshal() ([]byte, error) {
if err := saq.Validate(); err != nil {
return nil, err
}
buf := new(bytes.Buffer)
vaa.MustWrite(buf, binary.BigEndian, uint32(len(saq.Commitment)))
buf.Write([]byte(saq.Commitment))
vaa.MustWrite(buf, binary.BigEndian, saq.MinContextSlot)
vaa.MustWrite(buf, binary.BigEndian, saq.DataSliceOffset)
vaa.MustWrite(buf, binary.BigEndian, saq.DataSliceLength)
vaa.MustWrite(buf, binary.BigEndian, uint8(len(saq.Accounts)))
for _, acct := range saq.Accounts {
buf.Write(acct[:])
}
return buf.Bytes(), nil
}
// Unmarshal deserializes a Solana sol_account query from a byte array
func (saq *SolanaAccountQueryRequest) Unmarshal(data []byte) error {
reader := bytes.NewReader(data[:])
return saq.UnmarshalFromReader(reader)
}
// UnmarshalFromReader deserializes a Solana sol_account query from a byte array
func (saq *SolanaAccountQueryRequest) UnmarshalFromReader(reader *bytes.Reader) error {
len := uint32(0)
if err := binary.Read(reader, binary.BigEndian, &len); err != nil {
return fmt.Errorf("failed to read commitment len: %w", err)
}
if len > SolanaMaxCommitmentLength {
return fmt.Errorf("commitment string is too long, may not be more than %d characters", SolanaMaxCommitmentLength)
}
commitment := make([]byte, len)
if n, err := reader.Read(commitment[:]); err != nil || n != int(len) {
return fmt.Errorf("failed to read commitment [%d]: %w", n, err)
}
saq.Commitment = string(commitment)
if err := binary.Read(reader, binary.BigEndian, &saq.MinContextSlot); err != nil {
return fmt.Errorf("failed to read min slot: %w", err)
}
if err := binary.Read(reader, binary.BigEndian, &saq.DataSliceOffset); err != nil {
return fmt.Errorf("failed to read data slice offset: %w", err)
}
if err := binary.Read(reader, binary.BigEndian, &saq.DataSliceLength); err != nil {
return fmt.Errorf("failed to read data slice length: %w", err)
}
numAccounts := uint8(0)
if err := binary.Read(reader, binary.BigEndian, &numAccounts); err != nil {
return fmt.Errorf("failed to read number of account entries: %w", err)
}
for count := 0; count < int(numAccounts); count++ {
account := [SolanaPublicKeyLength]byte{}
if n, err := reader.Read(account[:]); err != nil || n != SolanaPublicKeyLength {
return fmt.Errorf("failed to read account [%d]: %w", n, err)
}
saq.Accounts = append(saq.Accounts, account)
}
return nil
}
// Validate does basic validation on a Solana sol_account query.
func (saq *SolanaAccountQueryRequest) Validate() error {
if len(saq.Commitment) > math.MaxUint32 {
return fmt.Errorf("commitment too long")
}
if saq.Commitment != "finalized" {
return fmt.Errorf(`commitment must be "finalized"`)
}
if saq.DataSliceLength == 0 && saq.DataSliceOffset != 0 {
return fmt.Errorf("data slice offset may not be set if data slice length is zero")
}
if len(saq.Accounts) <= 0 {
return fmt.Errorf("does not contain any account entries")
}
if len(saq.Accounts) > SolanaMaxAccountsPerQuery {
return fmt.Errorf("too many account entries, may not be more that %d", SolanaMaxAccountsPerQuery)
}
for _, acct := range saq.Accounts {
// The account is fixed length, so don't need to check for nil.
if len(acct) != SolanaPublicKeyLength {
return fmt.Errorf("invalid account length")
}
}
return nil
}
// Equal verifies that two Solana sol_account queries are equal.
func (left *SolanaAccountQueryRequest) Equal(right *SolanaAccountQueryRequest) bool {
if left.Commitment != right.Commitment ||
left.MinContextSlot != right.MinContextSlot ||
left.DataSliceOffset != right.DataSliceOffset ||
left.DataSliceLength != right.DataSliceLength {
return false
}
if len(left.Accounts) != len(right.Accounts) {
return false
}
for idx := range left.Accounts {
if !bytes.Equal(left.Accounts[idx][:], right.Accounts[idx][:]) {
return false
}
}
return true
}
func ValidatePerChainQueryRequestType(qt ChainSpecificQueryType) error {
if qt != EthCallQueryRequestType && qt != EthCallByTimestampQueryRequestType && qt != EthCallWithFinalityQueryRequestType {
if qt != EthCallQueryRequestType && qt != EthCallByTimestampQueryRequestType && qt != EthCallWithFinalityQueryRequestType && qt != SolanaAccountQueryRequestType {
return fmt.Errorf("invalid query request type: %d", qt)
}
return nil

View File

@ -14,6 +14,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/ethereum/go-ethereum/accounts/abi"
ethCommon "github.com/ethereum/go-ethereum/common"
)
func createQueryRequestForTesting(t *testing.T, chainId vaa.ChainID) *QueryRequest {
@ -725,7 +726,63 @@ func TestMarshalOfEthCallWithFinalityQueryWithSafeShouldSucceed(t *testing.T) {
require.NoError(t, err)
}
///////////// End of EthCallWithFinality tests /////////////////////////////////
///////////// Solana Account Query tests /////////////////////////////////
func createSolanaAccountQueryRequestForTesting(t *testing.T) *QueryRequest {
t.Helper()
callRequest1 := &SolanaAccountQueryRequest{
Commitment: "finalized",
Accounts: [][SolanaPublicKeyLength]byte{
ethCommon.HexToHash("0x9999bac44d09a7f69ee7941819b0a19c59ccb1969640cc513be09ef95ed2d8e2"),
ethCommon.HexToHash("0x9999bac44d09a7f69ee7941819b0a19c59ccb1969640cc513be09ef95ed2d8e3"),
},
}
perChainQuery1 := &PerChainQueryRequest{
ChainId: vaa.ChainIDSolana,
Query: callRequest1,
}
queryRequest := &QueryRequest{
Nonce: 1,
PerChainQueries: []*PerChainQueryRequest{perChainQuery1},
}
return queryRequest
}
func TestSolanaAccountQueryRequestMarshalUnmarshal(t *testing.T) {
queryRequest := createSolanaAccountQueryRequestForTesting(t)
queryRequestBytes, err := queryRequest.Marshal()
require.NoError(t, err)
var queryRequest2 QueryRequest
err = queryRequest2.Unmarshal(queryRequestBytes)
require.NoError(t, err)
assert.True(t, queryRequest.Equal(&queryRequest2))
}
func TestSolanaAccountQueryRequestMarshalUnmarshalFromSDK(t *testing.T) {
serialized, err := hex.DecodeString("0000000966696e616c697a656400000000000000000000000000000000000000000000000002165809739240a0ac03b98440fe8985548e3aa683cd0d4d9df5b5659669faa3019c006c48c8cbf33849cb07a3f936159cc523f9591cb1999abd45890ec5fee9b7")
require.NoError(t, err)
var solAccountReq SolanaAccountQueryRequest
err = solAccountReq.Unmarshal(serialized)
require.NoError(t, err)
}
func TestSolanaQueryMarshalUnmarshalFromSDK(t *testing.T) {
serialized, err := hex.DecodeString("010000002a01000104000000660000000966696e616c697a65640000000000000000000000000000000000000000000000000202c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa95f83a27e90c622a98c037353f271fd8f5f57b4dc18ebf5ff75a934724bd0491")
require.NoError(t, err)
var solQuery QueryRequest
err = solQuery.Unmarshal(serialized)
require.NoError(t, err)
}
///////////// End of Solana Account Query tests ///////////////////////////
func TestPostSignedQueryRequestShouldFailIfNoOneIsListening(t *testing.T) {
queryRequest := createQueryRequestForTesting(t, vaa.ChainIDPolygon)

View File

@ -107,6 +107,37 @@ type EthCallWithFinalityQueryResponse struct {
Results [][]byte
}
// SolanaAccountQueryResponse implements ChainSpecificResponse for a Solana sol_account query response.
type SolanaAccountQueryResponse struct {
// SlotNumber is the slot number returned by the sol_account query
SlotNumber uint64
// BlockTime is the block time associated with the slot.
BlockTime time.Time
// BlockHash is the block hash associated with the slot.
BlockHash [SolanaPublicKeyLength]byte
Results []SolanaAccountResult
}
type SolanaAccountResult struct {
// Lamports is the number of lamports assigned to the account.
Lamports uint64
// RentEpoch is the epoch at which this account will next owe rent.
RentEpoch uint64
// Executable is a boolean indicating if the account contains a program (and is strictly read-only).
Executable bool
// Owner is the public key of the owner of the account.
Owner [SolanaPublicKeyLength]byte
// Data is the data returned by the sol_account query.
Data []byte
}
//
// Implementation of QueryResponsePublication.
//
@ -224,6 +255,10 @@ func (msg *QueryResponsePublication) Unmarshal(data []byte) error {
return fmt.Errorf("excess bytes in unmarshal")
}
if err := msg.Validate(); err != nil {
return fmt.Errorf("unmarshaled response failed validation: %w", err)
}
return nil
}
@ -372,6 +407,12 @@ func (perChainResponse *PerChainQueryResponse) UnmarshalFromReader(reader *bytes
return fmt.Errorf("failed to unmarshal eth call with finality response: %w", err)
}
perChainResponse.Response = &r
case SolanaAccountQueryRequestType:
r := SolanaAccountQueryResponse{}
if err := r.UnmarshalFromReader(reader); err != nil {
return fmt.Errorf("failed to unmarshal sol_account response: %w", err)
}
perChainResponse.Response = &r
default:
return fmt.Errorf("unsupported query type: %d", queryType)
}
@ -419,25 +460,32 @@ func (left *PerChainQueryResponse) Equal(right *PerChainQueryResponse) bool {
return false
}
switch leftEcq := left.Response.(type) {
switch leftResp := left.Response.(type) {
case *EthCallQueryResponse:
switch rightEcd := right.Response.(type) {
switch rightResp := right.Response.(type) {
case *EthCallQueryResponse:
return leftEcq.Equal(rightEcd)
return leftResp.Equal(rightResp)
default:
panic("unsupported query type on right") // We checked this above!
}
case *EthCallByTimestampQueryResponse:
switch rightEcd := right.Response.(type) {
switch rightResp := right.Response.(type) {
case *EthCallByTimestampQueryResponse:
return leftEcq.Equal(rightEcd)
return leftResp.Equal(rightResp)
default:
panic("unsupported query type on right") // We checked this above!
}
case *EthCallWithFinalityQueryResponse:
switch rightEcd := right.Response.(type) {
switch rightResp := right.Response.(type) {
case *EthCallWithFinalityQueryResponse:
return leftEcq.Equal(rightEcd)
return leftResp.Equal(rightResp)
default:
panic("unsupported query type on right") // We checked this above!
}
case *SolanaAccountQueryResponse:
switch rightResp := right.Response.(type) {
case *SolanaAccountQueryResponse:
return leftResp.Equal(rightResp)
default:
panic("unsupported query type on right") // We checked this above!
}
@ -576,7 +624,7 @@ func (e *EthCallByTimestampQueryResponse) Type() ChainSpecificQueryType {
return EthCallByTimestampQueryRequestType
}
// Marshal serializes the binary representation of an EVM eth_call response.
// Marshal serializes the binary representation of an EVM eth_call_by_timestamp response.
// This method calls Validate() and relies on it to range checks lengths, etc.
func (ecr *EthCallByTimestampQueryResponse) Marshal() ([]byte, error) {
if err := ecr.Validate(); err != nil {
@ -601,13 +649,13 @@ func (ecr *EthCallByTimestampQueryResponse) Marshal() ([]byte, error) {
return buf.Bytes(), nil
}
// Unmarshal deserializes an EVM eth_call response from a byte array
// Unmarshal deserializes an EVM eth_call_by_timestamp response from a byte array
func (ecr *EthCallByTimestampQueryResponse) Unmarshal(data []byte) error {
reader := bytes.NewReader(data[:])
return ecr.UnmarshalFromReader(reader)
}
// UnmarshalFromReader deserializes an EVM eth_call response from a byte array
// UnmarshalFromReader deserializes an EVM eth_call_by_timestamp response from a byte array
func (ecr *EthCallByTimestampQueryResponse) UnmarshalFromReader(reader *bytes.Reader) error {
if err := binary.Read(reader, binary.BigEndian, &ecr.TargetBlockNumber); err != nil {
return fmt.Errorf("failed to read response target block number: %w", err)
@ -662,7 +710,7 @@ func (ecr *EthCallByTimestampQueryResponse) UnmarshalFromReader(reader *bytes.Re
return nil
}
// Validate does basic validation on an EVM eth_call response.
// Validate does basic validation on an EVM eth_call_by_timestamp response.
func (ecr *EthCallByTimestampQueryResponse) Validate() error {
// Not checking for block numbers == 0, because maybe that could happen??
@ -688,7 +736,7 @@ func (ecr *EthCallByTimestampQueryResponse) Validate() error {
return nil
}
// Equal verifies that two EVM eth_call responses are equal.
// Equal verifies that two EVM eth_call_by_timestamp responses are equal.
func (left *EthCallByTimestampQueryResponse) Equal(right *EthCallByTimestampQueryResponse) bool {
if left.TargetBlockNumber != right.TargetBlockNumber {
return false
@ -734,7 +782,7 @@ func (e *EthCallWithFinalityQueryResponse) Type() ChainSpecificQueryType {
return EthCallWithFinalityQueryRequestType
}
// Marshal serializes the binary representation of an EVM eth_call response.
// Marshal serializes the binary representation of an EVM eth_call_with_finality response.
// This method calls Validate() and relies on it to range checks lengths, etc.
func (ecr *EthCallWithFinalityQueryResponse) Marshal() ([]byte, error) {
if err := ecr.Validate(); err != nil {
@ -755,13 +803,13 @@ func (ecr *EthCallWithFinalityQueryResponse) Marshal() ([]byte, error) {
return buf.Bytes(), nil
}
// Unmarshal deserializes an EVM eth_call response from a byte array
// Unmarshal deserializes an EVM eth_call_with_finality response from a byte array
func (ecr *EthCallWithFinalityQueryResponse) Unmarshal(data []byte) error {
reader := bytes.NewReader(data[:])
return ecr.UnmarshalFromReader(reader)
}
// UnmarshalFromReader deserializes an EVM eth_call response from a byte array
// UnmarshalFromReader deserializes an EVM eth_call_with_finality response from a byte array
func (ecr *EthCallWithFinalityQueryResponse) UnmarshalFromReader(reader *bytes.Reader) error {
if err := binary.Read(reader, binary.BigEndian, &ecr.BlockNumber); err != nil {
return fmt.Errorf("failed to read response number: %w", err)
@ -800,7 +848,7 @@ func (ecr *EthCallWithFinalityQueryResponse) UnmarshalFromReader(reader *bytes.R
return nil
}
// Validate does basic validation on an EVM eth_call response.
// Validate does basic validation on an EVM eth_call_with_finality response.
func (ecr *EthCallWithFinalityQueryResponse) Validate() error {
// Not checking for BlockNumber == 0, because maybe that could happen??
@ -822,7 +870,7 @@ func (ecr *EthCallWithFinalityQueryResponse) Validate() error {
return nil
}
// Equal verifies that two EVM eth_call responses are equal.
// Equal verifies that two EVM eth_call_with_finality responses are equal.
func (left *EthCallWithFinalityQueryResponse) Equal(right *EthCallWithFinalityQueryResponse) bool {
if left.BlockNumber != right.BlockNumber {
return false
@ -847,3 +895,150 @@ func (left *EthCallWithFinalityQueryResponse) Equal(right *EthCallWithFinalityQu
return true
}
//
// Implementation of SolanaAccountQueryResponse, which implements the ChainSpecificResponse for a Solana sol_account query response.
//
func (sar *SolanaAccountQueryResponse) Type() ChainSpecificQueryType {
return SolanaAccountQueryRequestType
}
// Marshal serializes the binary representation of a Solana sol_account response.
// This method calls Validate() and relies on it to range checks lengths, etc.
func (sar *SolanaAccountQueryResponse) Marshal() ([]byte, error) {
if err := sar.Validate(); err != nil {
return nil, err
}
buf := new(bytes.Buffer)
vaa.MustWrite(buf, binary.BigEndian, sar.SlotNumber)
vaa.MustWrite(buf, binary.BigEndian, sar.BlockTime.UnixMicro())
buf.Write(sar.BlockHash[:])
vaa.MustWrite(buf, binary.BigEndian, uint8(len(sar.Results)))
for _, res := range sar.Results {
vaa.MustWrite(buf, binary.BigEndian, res.Lamports)
vaa.MustWrite(buf, binary.BigEndian, res.RentEpoch)
vaa.MustWrite(buf, binary.BigEndian, res.Executable)
buf.Write(res.Owner[:])
vaa.MustWrite(buf, binary.BigEndian, uint32(len(res.Data)))
buf.Write(res.Data)
}
return buf.Bytes(), nil
}
// Unmarshal deserializes a Solana sol_account response from a byte array
func (sar *SolanaAccountQueryResponse) Unmarshal(data []byte) error {
reader := bytes.NewReader(data[:])
return sar.UnmarshalFromReader(reader)
}
// UnmarshalFromReader deserializes a Solana sol_account response from a byte array
func (sar *SolanaAccountQueryResponse) UnmarshalFromReader(reader *bytes.Reader) error {
if err := binary.Read(reader, binary.BigEndian, &sar.SlotNumber); err != nil {
return fmt.Errorf("failed to read slot number: %w", err)
}
blockTime := int64(0)
if err := binary.Read(reader, binary.BigEndian, &blockTime); err != nil {
return fmt.Errorf("failed to read block time: %w", err)
}
sar.BlockTime = time.UnixMicro(blockTime)
if n, err := reader.Read(sar.BlockHash[:]); err != nil || n != SolanaPublicKeyLength {
return fmt.Errorf("failed to read block hash [%d]: %w", n, err)
}
numResults := uint8(0)
if err := binary.Read(reader, binary.BigEndian, &numResults); err != nil {
return fmt.Errorf("failed to read number of results: %w", err)
}
for count := 0; count < int(numResults); count++ {
var result SolanaAccountResult
if err := binary.Read(reader, binary.BigEndian, &result.Lamports); err != nil {
return fmt.Errorf("failed to read lamports: %w", err)
}
if err := binary.Read(reader, binary.BigEndian, &result.RentEpoch); err != nil {
return fmt.Errorf("failed to read rent epoch: %w", err)
}
if err := binary.Read(reader, binary.BigEndian, &result.Executable); err != nil {
return fmt.Errorf("failed to read executable flag: %w", err)
}
if n, err := reader.Read(result.Owner[:]); err != nil || n != SolanaPublicKeyLength {
return fmt.Errorf("failed to read owner [%d]: %w", n, err)
}
len := uint32(0)
if err := binary.Read(reader, binary.BigEndian, &len); err != nil {
return fmt.Errorf("failed to read data len: %w", err)
}
result.Data = make([]byte, len)
if n, err := reader.Read(result.Data[:]); err != nil || n != int(len) {
return fmt.Errorf("failed to read data [%d]: %w", n, err)
}
sar.Results = append(sar.Results, result)
}
return nil
}
// Validate does basic validation on a Solana sol_account response.
func (sar *SolanaAccountQueryResponse) Validate() error {
// Not checking for SlotNumber == 0, because maybe that could happen??
// Not checking for BlockTime == 0, because maybe that could happen??
// The block hash is fixed length, so don't need to check for nil.
if len(sar.BlockHash) != SolanaPublicKeyLength {
return fmt.Errorf("invalid block hash length")
}
if len(sar.Results) <= 0 {
return fmt.Errorf("does not contain any results")
}
if len(sar.Results) > math.MaxUint8 {
return fmt.Errorf("too many results")
}
for _, result := range sar.Results {
// Owner is fixed length, so don't need to check for nil.
if len(result.Owner) != SolanaPublicKeyLength {
return fmt.Errorf("invalid owner length")
}
if len(result.Data) > math.MaxUint32 {
return fmt.Errorf("data too long")
}
}
return nil
}
// Equal verifies that two Solana sol_account responses are equal.
func (left *SolanaAccountQueryResponse) Equal(right *SolanaAccountQueryResponse) bool {
if left.SlotNumber != right.SlotNumber ||
left.BlockTime != right.BlockTime ||
!bytes.Equal(left.BlockHash[:], right.BlockHash[:]) {
return false
}
if len(left.Results) != len(right.Results) {
return false
}
for idx := range left.Results {
if left.Results[idx].Lamports != right.Results[idx].Lamports ||
left.Results[idx].RentEpoch != right.Results[idx].RentEpoch ||
left.Results[idx].Executable != right.Results[idx].Executable ||
!bytes.Equal(left.Results[idx].Owner[:], right.Results[idx].Owner[:]) ||
!bytes.Equal(left.Results[idx].Data, right.Results[idx].Data) {
return false
}
}
return true
}

View File

@ -262,3 +262,67 @@ func TestMarshalUnmarshalQueryResponseWithTooManyResultsShouldFail(t *testing.T)
_, err := respPub.Marshal()
require.Error(t, err)
}
///////////// Solana Account Query tests /////////////////////////////////
func createSolanaAccountQueryResponseFromRequest(t *testing.T, queryRequest *QueryRequest) *QueryResponsePublication {
queryRequestBytes, err := queryRequest.Marshal()
require.NoError(t, err)
sig := [65]byte{}
signedQueryRequest := &gossipv1.SignedQueryRequest{
QueryRequest: queryRequestBytes,
Signature: sig[:],
}
perChainResponses := []*PerChainQueryResponse{}
for idx, pcr := range queryRequest.PerChainQueries {
switch req := pcr.Query.(type) {
case *SolanaAccountQueryRequest:
results := []SolanaAccountResult{}
for idx := range req.Accounts {
results = append(results, SolanaAccountResult{
Lamports: uint64(2000 + idx),
RentEpoch: uint64(3000 + idx),
Executable: (idx%2 == 0),
Owner: ethCommon.HexToHash("0x9999bac44d09a7f69ee7941819b0a19c59ccb1969640cc513be09ef95ed2d8e2"),
Data: []byte([]byte(fmt.Sprintf("Result %d", idx))),
})
}
perChainResponses = append(perChainResponses, &PerChainQueryResponse{
ChainId: pcr.ChainId,
Response: &SolanaAccountQueryResponse{
SlotNumber: uint64(1000 + idx),
BlockTime: timeForTest(t, time.Now()),
BlockHash: ethCommon.HexToHash("0x9999bac44d09a7f69ee7941819b0a19c59ccb1969640cc513be09ef95ed2d8e3"),
Results: results,
},
})
default:
panic("invalid query type!")
}
}
return &QueryResponsePublication{
Request: signedQueryRequest,
PerChainResponses: perChainResponses,
}
}
func TestSolanaAccountQueryResponseMarshalUnmarshal(t *testing.T) {
queryRequest := createSolanaAccountQueryRequestForTesting(t)
respPub := createSolanaAccountQueryResponseFromRequest(t, queryRequest)
respPubBytes, err := respPub.Marshal()
require.NoError(t, err)
var respPub2 QueryResponsePublication
err = respPub2.Unmarshal(respPubBytes)
require.NoError(t, err)
require.NotNil(t, respPub2)
assert.True(t, respPub.Equal(&respPub2))
}
///////////// End of Solana Account Query tests ///////////////////////////

View File

@ -24,7 +24,7 @@ func (w *Watcher) ccqSendQueryResponse(req *query.PerChainQueryInternal, status
queryResponse := query.CreatePerChainQueryResponseInternal(req.RequestID, req.RequestIdx, req.Request.ChainId, status, response)
select {
case w.queryResponseC <- queryResponse:
w.ccqLogger.Debug("published query response error to handler")
w.ccqLogger.Debug("published query response to handler")
default:
w.ccqLogger.Error("failed to published query response error to handler")
}

View File

@ -0,0 +1,176 @@
package solana
import (
"context"
"encoding/hex"
"time"
"go.uber.org/zap"
"github.com/certusone/wormhole/node/pkg/query"
"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/rpc"
)
// ccqSendQueryResponse sends a response back to the query handler. In the case of an error, the response parameter may be nil.
func (w *SolanaWatcher) ccqSendQueryResponse(req *query.PerChainQueryInternal, status query.QueryStatus, response query.ChainSpecificResponse) {
queryResponse := query.CreatePerChainQueryResponseInternal(req.RequestID, req.RequestIdx, req.Request.ChainId, status, response)
select {
case w.queryResponseC <- queryResponse:
w.ccqLogger.Debug("published query response to handler")
default:
w.ccqLogger.Error("failed to published query response error to handler")
}
}
// ccqHandleQuery is the top-level query handler. It breaks out the requests based on the type and calls the appropriate handler.
func (w *SolanaWatcher) ccqHandleQuery(ctx context.Context, queryRequest *query.PerChainQueryInternal) {
// This can't happen unless there is a programming error - the caller
// is expected to send us only requests for our chainID.
if queryRequest.Request.ChainId != w.chainID {
panic("ccqevm: invalid chain ID")
}
start := time.Now()
switch req := queryRequest.Request.Query.(type) {
case *query.SolanaAccountQueryRequest:
w.ccqHandleSolanaAccountQueryRequest(ctx, queryRequest, req)
default:
w.ccqLogger.Warn("received unsupported request type",
zap.Uint8("payload", uint8(queryRequest.Request.Query.Type())),
)
w.ccqSendQueryResponse(queryRequest, query.QueryFatalError, nil)
}
query.TotalWatcherTime.WithLabelValues(w.chainID.String()).Observe(float64(time.Since(start).Milliseconds()))
}
// ccqHandleSolanaAccountQueryRequest is the query handler for a sol_account request.
func (w *SolanaWatcher) ccqHandleSolanaAccountQueryRequest(ctx context.Context, queryRequest *query.PerChainQueryInternal, req *query.SolanaAccountQueryRequest) {
requestId := "sol_account:" + queryRequest.ID()
w.ccqLogger.Info("received a sol_account query", zap.String("requestId", requestId))
rCtx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
// Convert the accounts from byte arrays to public keys.
accounts := make([]solana.PublicKey, 0, len(req.Accounts))
for _, acct := range req.Accounts {
accounts = append(accounts, acct)
}
// Create the parameters needed for the account read and add any optional parameters.
params := rpc.GetMultipleAccountsOpts{
Encoding: solana.EncodingBase64,
Commitment: rpc.CommitmentType(req.Commitment),
}
if req.MinContextSlot != 0 {
params.MinContextSlot = &req.MinContextSlot
}
if req.DataSliceLength != 0 {
params.DataSlice = &rpc.DataSlice{
Offset: &req.DataSliceOffset,
Length: &req.DataSliceLength,
}
}
// Read the accounts.
info, err := w.rpcClient.GetMultipleAccountsWithOpts(rCtx, accounts, &params)
if err != nil {
w.ccqLogger.Error("read failed for sol_account query request",
zap.String("requestId", requestId),
zap.Any("accounts", accounts),
zap.Any("params", params),
zap.Error(err),
)
w.ccqSendQueryResponse(queryRequest, query.QueryRetryNeeded, nil)
return
}
// Read the block for this slot to get the block time.
maxSupportedTransactionVersion := uint64(0)
block, err := w.rpcClient.GetBlockWithOpts(rCtx, info.Context.Slot, &rpc.GetBlockOpts{
Encoding: solana.EncodingBase64,
Commitment: params.Commitment,
TransactionDetails: rpc.TransactionDetailsNone,
MaxSupportedTransactionVersion: &maxSupportedTransactionVersion,
})
if err != nil {
w.ccqLogger.Error("failed to read block time for sol_account query request",
zap.String("requestId", requestId),
zap.Uint64("slotNumber", info.Context.Slot),
zap.Error(err),
)
w.ccqSendQueryResponse(queryRequest, query.QueryRetryNeeded, nil)
return
}
if info == nil {
w.ccqLogger.Error("read for sol_account query request returned nil info", zap.String("requestId", requestId))
w.ccqSendQueryResponse(queryRequest, query.QueryFatalError, nil)
return
}
if info.Value == nil {
w.ccqLogger.Error("read for sol_account query request returned nil value", zap.String("requestId", requestId))
w.ccqSendQueryResponse(queryRequest, query.QueryFatalError, nil)
return
}
if len(info.Value) != len(req.Accounts) {
w.ccqLogger.Error("read for sol_account query request returned unexpected number of results",
zap.String("requestId", requestId),
zap.Int("numAccounts", len(req.Accounts)),
zap.Int("numValues", len(info.Value)),
)
w.ccqSendQueryResponse(queryRequest, query.QueryFatalError, nil)
return
}
// Extract the results.
results := make([]query.SolanaAccountResult, 0, len(req.Accounts))
for idx, val := range info.Value {
if val == nil { // This can happen for an invalid account.
w.ccqLogger.Error("read of account for sol_account query request failed, val is nil", zap.String("requestId", requestId), zap.Any("account", req.Accounts[idx]))
w.ccqSendQueryResponse(queryRequest, query.QueryFatalError, nil)
return
}
if val.Data == nil {
w.ccqLogger.Error("read of account for sol_account query request failed, data is nil", zap.String("requestId", requestId), zap.Any("account", req.Accounts[idx]))
w.ccqSendQueryResponse(queryRequest, query.QueryFatalError, nil)
return
}
results = append(results, query.SolanaAccountResult{
Lamports: val.Lamports,
RentEpoch: val.RentEpoch,
Executable: val.Executable,
Owner: val.Owner,
Data: val.Data.GetBinary(),
})
}
// Finally, build the response and publish it.
resp := &query.SolanaAccountQueryResponse{
SlotNumber: info.Context.Slot,
BlockTime: time.Unix(int64(*block.BlockTime), 0),
BlockHash: block.Blockhash,
Results: results,
}
w.ccqLogger.Info("account read for sol_account_query succeeded",
zap.String("requestId", requestId),
zap.Uint64("slotNumber", info.Context.Slot),
zap.Uint64("blockTime", uint64(*block.BlockTime)),
zap.String("blockHash", hex.EncodeToString(block.Blockhash[:])),
zap.Any("blockHeight", block.BlockHeight),
)
w.ccqSendQueryResponse(queryRequest, query.QuerySuccess, resp)
}

View File

@ -15,6 +15,7 @@ import (
"github.com/certusone/wormhole/node/pkg/common"
"github.com/certusone/wormhole/node/pkg/p2p"
gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1"
"github.com/certusone/wormhole/node/pkg/query"
"github.com/certusone/wormhole/node/pkg/readiness"
"github.com/certusone/wormhole/node/pkg/supervisor"
eth_common "github.com/ethereum/go-ethereum/common"
@ -58,6 +59,15 @@ type (
// latestFinalizedBlockNumber is the latest block processed by this watcher.
latestBlockNumber uint64
latestBlockNumberMu sync.Mutex
// Incoming query requests from the network. Pre-filtered to only
// include requests for our chainID.
queryReqC <-chan *query.PerChainQueryInternal
// Outbound query responses to query requests
queryResponseC chan<- *query.PerChainQueryResponseInternal
ccqLogger *zap.Logger
}
EventSubscriptionError struct {
@ -200,19 +210,24 @@ func NewSolanaWatcher(
msgC chan<- *common.MessagePublication,
obsvReqC <-chan *gossipv1.ObservationRequest,
commitment rpc.CommitmentType,
chainID vaa.ChainID) *SolanaWatcher {
chainID vaa.ChainID,
queryReqC <-chan *query.PerChainQueryInternal,
queryResponseC chan<- *query.PerChainQueryResponseInternal,
) *SolanaWatcher {
return &SolanaWatcher{
rpcUrl: rpcUrl,
wsUrl: wsUrl,
contract: contractAddress,
rawContract: rawContract,
msgC: msgC,
obsvReqC: obsvReqC,
commitment: commitment,
rpcClient: rpc.New(rpcUrl),
readinessSync: common.MustConvertChainIdToReadinessSyncing(chainID),
chainID: chainID,
networkName: chainID.String(),
rpcUrl: rpcUrl,
wsUrl: wsUrl,
contract: contractAddress,
rawContract: rawContract,
msgC: msgC,
obsvReqC: obsvReqC,
commitment: commitment,
rpcClient: rpc.New(rpcUrl),
readinessSync: common.MustConvertChainIdToReadinessSyncing(chainID),
chainID: chainID,
networkName: chainID.String(),
queryReqC: queryReqC,
queryResponseC: queryResponseC,
}
}
@ -398,6 +413,20 @@ func (s *SolanaWatcher) Run(ctx context.Context) error {
}
})
if s.commitment == rpc.CommitmentType("finalized") {
s.ccqLogger = logger.With(zap.String("component", "ccqsol"))
common.RunWithScissors(ctx, s.errC, "solana_fetch_query_req", func(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return nil
case queryRequest := <-s.queryReqC:
s.ccqHandleQuery(ctx, queryRequest)
}
}
})
}
select {
case <-ctx.Done():
return ctx.Err()

View File

@ -41,8 +41,8 @@ func (wc *WatcherConfig) SetL1Finalizer(l1finalizer interfaces.L1Finalizer) {
func (wc *WatcherConfig) Create(
msgC chan<- *common.MessagePublication,
obsvReqC <-chan *gossipv1.ObservationRequest,
_ <-chan *query.PerChainQueryInternal,
_ chan<- *query.PerChainQueryResponseInternal,
queryReqC <-chan *query.PerChainQueryInternal,
queryResponseC chan<- *query.PerChainQueryResponseInternal,
_ chan<- *common.GuardianSet,
env common.Environment,
) (interfaces.L1Finalizer, supervisor.Runnable, error) {
@ -55,7 +55,7 @@ func (wc *WatcherConfig) Create(
obsvReqC = nil
}
watcher := NewSolanaWatcher(wc.Rpc, &wc.Websocket, solAddress, wc.Contract, msgC, obsvReqC, wc.Commitment, wc.ChainID)
watcher := NewSolanaWatcher(wc.Rpc, &wc.Websocket, solAddress, wc.Contract, msgC, obsvReqC, wc.Commitment, wc.ChainID, queryReqC, queryResponseC)
return watcher, watcher.Run, nil
}

View File

@ -1,20 +1,22 @@
{
"name": "@wormhole-foundation/wormhole-query-sdk",
"version": "0.0.3",
"version": "0.0.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@wormhole-foundation/wormhole-query-sdk",
"version": "0.0.3",
"version": "0.0.6",
"license": "Apache-2.0",
"dependencies": {
"@ethersproject/keccak256": "^5.7.0",
"@types/elliptic": "^6.4.14",
"bs58": "^4.0.1",
"buffer": "^6.0.3",
"elliptic": "^6.5.4"
},
"devDependencies": {
"@types/bs58": "^4.0.4",
"axios": "^1.4.0",
"jest": "^29.5.0",
"prettier": "^2.3.2",
@ -1539,6 +1541,16 @@
"@types/node": "*"
}
},
"node_modules/@types/bs58": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/bs58/-/bs58-4.0.4.tgz",
"integrity": "sha512-0IEpMFXXQi2zXaXl9GJ3sRwQo0uEkD+yFOv+FnAU5lkPtcu6h61xb7jc2CFPEZ5BUOaiP13ThuGc9HD4R8lR5g==",
"dev": true,
"dependencies": {
"@types/node": "*",
"base-x": "^3.0.6"
}
},
"node_modules/@types/elliptic": {
"version": "6.4.14",
"resolved": "https://registry.npmjs.org/@types/elliptic/-/elliptic-6.4.14.tgz",
@ -1835,6 +1847,14 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/base-x": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.9.tgz",
"integrity": "sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -1931,6 +1951,14 @@
"node": ">= 6"
}
},
"node_modules/bs58": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
"integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==",
"dependencies": {
"base-x": "^3.0.2"
}
},
"node_modules/bser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
@ -4114,6 +4142,25 @@
"node": ">=10"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
@ -6178,6 +6225,16 @@
"@types/node": "*"
}
},
"@types/bs58": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/bs58/-/bs58-4.0.4.tgz",
"integrity": "sha512-0IEpMFXXQi2zXaXl9GJ3sRwQo0uEkD+yFOv+FnAU5lkPtcu6h61xb7jc2CFPEZ5BUOaiP13ThuGc9HD4R8lR5g==",
"dev": true,
"requires": {
"@types/node": "*",
"base-x": "^3.0.6"
}
},
"@types/elliptic": {
"version": "6.4.14",
"resolved": "https://registry.npmjs.org/@types/elliptic/-/elliptic-6.4.14.tgz",
@ -6420,6 +6477,14 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"base-x": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.9.tgz",
"integrity": "sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==",
"requires": {
"safe-buffer": "^5.0.1"
}
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -6476,6 +6541,14 @@
"fast-json-stable-stringify": "2.x"
}
},
"bs58": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
"integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==",
"requires": {
"base-x": "^3.0.2"
}
},
"bser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
@ -8067,6 +8140,11 @@
"integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==",
"dev": true
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",

View File

@ -23,6 +23,7 @@
"author": "Wormhole Foundation",
"license": "Apache-2.0",
"devDependencies": {
"@types/bs58": "^4.0.4",
"axios": "^1.4.0",
"jest": "^29.5.0",
"prettier": "^2.3.2",
@ -35,6 +36,7 @@
"dependencies": {
"@ethersproject/keccak256": "^5.7.0",
"@types/elliptic": "^6.4.14",
"bs58": "^4.0.1",
"buffer": "^6.0.3",
"elliptic": "^6.5.4"
}

View File

@ -1,4 +1,5 @@
import axios from "axios";
import base58 from "bs58";
import { Buffer } from "buffer";
import {
ChainQueryType,
@ -13,9 +14,28 @@ import {
QueryRequest,
QueryResponse,
sign,
SolanaAccountQueryRequest,
SolanaAccountQueryResponse,
SolanaAccountResult,
} from "../query";
import { BinaryWriter } from "../query/BinaryWriter";
type SolanaAccountData = {
data: [string, string];
executable: boolean;
lamports: number;
owner: string;
rentEpoch: number;
space: number;
};
type SolanaGetMultipleAccountsResponse = {
result?: {
context: { apiVersion: string; slot: number };
value: SolanaAccountData[];
};
};
/**
* Usage:
*
@ -348,6 +368,102 @@ export class QueryProxyMock {
)
)
);
} else if (type === ChainQueryType.SolanaAccount) {
const query = perChainRequest.query as SolanaAccountQueryRequest;
// Validate the request.
if (query.commitment !== "finalized") {
throw new Error(
`Invalid commitment in sol_account query request, must be "finalized"`
);
}
if (
query.dataSliceLength === BigInt(0) &&
query.dataSliceOffset !== BigInt(0)
) {
throw new Error(
`data slice offset may not be set if data slice length is zero`
);
}
if (query.accounts.length <= 0) {
throw new Error(`does not contain any account entries`);
}
if (query.accounts.length > 255) {
throw new Error(`too many account entries`);
}
let accounts: string[] = [];
query.accounts.forEach((acct) => {
if (acct.length != 32) {
throw new Error(`invalid account length`);
}
accounts.push(base58.encode(acct));
});
let opts =
query.dataSliceOffset === BigInt(0)
? {
commitment: query.commitment,
}
: {
commitment: query.commitment,
dataSlice: {
offset: Number(query.dataSliceOffset),
length: Number(query.dataSliceLength),
},
};
const response = await axios.post<SolanaGetMultipleAccountsResponse>(
rpc,
{
jsonrpc: "2.0",
id: 1,
method: "getMultipleAccounts",
params: [accounts, opts],
}
);
if (!response.data.result) {
throw new Error("Invalid result for getMultipleAccounts");
}
const slotNumber = response.data.result.context.slot;
let results: SolanaAccountResult[] = [];
response.data.result.value.forEach((val) => {
results.push({
lamports: BigInt(val.lamports),
rentEpoch: BigInt(val.rentEpoch),
executable: Boolean(val.executable),
owner: Uint8Array.from(base58.decode(val.owner.toString())),
data: Uint8Array.from(
Buffer.from(val.data[0].toString(), "base64")
),
});
});
const response2 = await axios.post(rpc, {
jsonrpc: "2.0",
id: 1,
method: "getBlock",
params: [
slotNumber,
{ commitment: query.commitment, transactionDetails: "none" },
],
});
const blockTime = response2.data.result.blockTime;
const blockHash = base58.decode(response2.data.result.blockhash);
queryResponse.responses.push(
new PerChainQueryResponse(
perChainRequest.chainId,
new SolanaAccountQueryResponse(
BigInt(slotNumber),
BigInt(blockTime) * BigInt(1000000), // time in seconds -> microseconds,
blockHash,
results
)
)
);
} else {
throw new Error(`Unsupported query type for mock: ${type}`);
}

View File

@ -16,10 +16,14 @@ import {
QueryProxyMock,
QueryProxyQueryResponse,
QueryRequest,
QueryResponse,
SolanaAccountQueryRequest,
SolanaAccountQueryResponse,
} from "..";
jest.setTimeout(60000);
const SOLANA_NODE_URL = "http://localhost:8899";
const POLYGON_NODE_URL = "https://polygon-mumbai-bor.publicnode.com";
const ARBITRUM_NODE_URL = "https://arbitrum-goerli.publicnode.com";
const QUERY_URL = "https://testnet.ccq.vaa.dev/v1/query";
@ -28,6 +32,7 @@ let mock: QueryProxyMock;
beforeAll(() => {
mock = new QueryProxyMock({
1: SOLANA_NODE_URL,
5: POLYGON_NODE_URL,
23: ARBITRUM_NODE_URL,
});
@ -232,4 +237,56 @@ describe.skip("mocks match testnet", () => {
);
}
});
test("SolAccount to devnet", async () => {
const accounts = [
"2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ", // Example token in devnet
"BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna", // Example NFT in devnet
];
const query = new QueryRequest(42, [
new PerChainQueryRequest(
1,
new SolanaAccountQueryRequest("finalized", accounts)
),
]);
const resp = await mock.mock(query);
const queryResponse = QueryResponse.from(resp.bytes);
const sar = queryResponse.responses[0]
.response as SolanaAccountQueryResponse;
expect(Buffer.from(sar.results[0].data).toString("hex")).toEqual(
"01000000574108aed69daf7e625a361864b1f74d13702f2ca56de9660e566d1d8691848d0000e8890423c78a0901000000000000000000000000000000000000000000000000000000000000000000000000"
);
expect(Buffer.from(sar.results[1].data).toString("hex")).toEqual(
"01000000574108aed69daf7e625a361864b1f74d13702f2ca56de9660e566d1d8691848d01000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000"
);
});
test("SolAccount to devnet with data slice", async () => {
const accounts = [
"2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ", // Example token in devnet
"BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna", // Example NFT in devnet
];
const query = new QueryRequest(42, [
new PerChainQueryRequest(
1,
new SolanaAccountQueryRequest(
"finalized",
accounts,
BigInt(0),
BigInt(1),
BigInt(10)
)
),
]);
const resp = await mock.mock(query);
const queryResponse = QueryResponse.from(resp.bytes);
const sar = queryResponse.responses[0]
.response as SolanaAccountQueryResponse;
expect(Buffer.from(sar.results[0].data).toString("hex")).toEqual(
"000000574108aed69daf"
);
expect(Buffer.from(sar.results[1].data).toString("hex")).toEqual(
"000000574108aed69daf"
);
});
});

View File

@ -579,7 +579,7 @@ describe("eth call", () => {
err = true;
expect(error.response.status).toBe(400);
expect(error.response.data).toBe(
`failed to validate request: failed to validate per chain query 0: chain specific query is invalid: target timestamp may not be zero\n`
`failed to unmarshal request: unmarshaled request failed validation: failed to validate per chain query 0: chain specific query is invalid: target timestamp may not be zero\n`
);
});
expect(err).toBe(true);
@ -624,7 +624,7 @@ describe("eth call", () => {
err = true;
expect(error.response.status).toBe(400);
expect(error.response.data).toBe(
`failed to validate request: failed to validate per chain query 0: chain specific query is invalid: if either the target or following block id is unset, they both must be unset\n`
`failed to unmarshal request: unmarshaled request failed validation: failed to validate per chain query 0: chain specific query is invalid: if either the target or following block id is unset, they both must be unset\n`
);
});
expect(err).toBe(true);
@ -668,7 +668,7 @@ describe("eth call", () => {
err = true;
expect(error.response.status).toBe(400);
expect(error.response.data).toBe(
`failed to validate request: failed to validate per chain query 0: chain specific query is invalid: if either the target or following block id is unset, they both must be unset\n`
`failed to unmarshal request: unmarshaled request failed validation: failed to validate per chain query 0: chain specific query is invalid: if either the target or following block id is unset, they both must be unset\n`
);
});
expect(err).toBe(true);
@ -786,7 +786,7 @@ describe("eth call", () => {
err = true;
expect(error.response.status).toBe(400);
expect(error.response.data).toBe(
`failed to validate request: failed to validate per chain query 0: chain specific query is invalid: finality is required\n`
`failed to unmarshal request: unmarshaled request failed validation: failed to validate per chain query 0: chain specific query is invalid: finality is required\n`
);
});
expect(err).toBe(true);
@ -824,7 +824,7 @@ describe("eth call", () => {
err = true;
expect(error.response.status).toBe(400);
expect(error.response.data).toBe(
`failed to validate request: failed to validate per chain query 0: chain specific query is invalid: finality must be "finalized" or "safe", is "HelloWorld"\n`
`failed to unmarshal request: unmarshaled request failed validation: failed to validate per chain query 0: chain specific query is invalid: finality must be "finalized" or "safe", is "HelloWorld"\n`
);
});
expect(err).toBe(true);

View File

@ -5,4 +5,5 @@ export * from "./utils";
export * from "./ethCall";
export * from "./ethCallByTimestamp";
export * from "./ethCallWithFinality";
export * from "./solanaAccount";
export * from "./consts";

View File

@ -7,6 +7,7 @@ import { BinaryReader } from "./BinaryReader";
import { EthCallQueryRequest } from "./ethCall";
import { EthCallByTimestampQueryRequest } from "./ethCallByTimestamp";
import { EthCallWithFinalityQueryRequest } from "./ethCallWithFinality";
import { SolanaAccountQueryRequest } from "./solanaAccount";
export const MAINNET_QUERY_REQUEST_PREFIX =
"mainnet_query_request_000000000000|";
@ -101,6 +102,8 @@ export class PerChainQueryRequest {
query = EthCallByTimestampQueryRequest.fromReader(reader);
} else if (queryType === ChainQueryType.EthCallWithFinality) {
query = EthCallWithFinalityQueryRequest.fromReader(reader);
} else if (queryType === ChainQueryType.SolanaAccount) {
query = SolanaAccountQueryRequest.fromReader(reader);
} else {
throw new Error(`Unsupported query type: ${queryType}`);
}
@ -117,4 +120,5 @@ export enum ChainQueryType {
EthCall = 1,
EthCallByTimeStamp = 2,
EthCallWithFinality = 3,
SolanaAccount = 4,
}

View File

@ -7,6 +7,7 @@ import { BinaryWriter } from "./BinaryWriter";
import { EthCallQueryResponse } from "./ethCall";
import { EthCallByTimestampQueryResponse } from "./ethCallByTimestamp";
import { EthCallWithFinalityQueryResponse } from "./ethCallWithFinality";
import { SolanaAccountQueryResponse } from "./solanaAccount";
export const QUERY_RESPONSE_PREFIX = "query_response_0000000000000000000|";
@ -109,6 +110,8 @@ export class PerChainQueryResponse {
response = EthCallByTimestampQueryResponse.fromReader(reader);
} else if (queryType === ChainQueryType.EthCallWithFinality) {
response = EthCallWithFinalityQueryResponse.fromReader(reader);
} else if (queryType === ChainQueryType.SolanaAccount) {
response = SolanaAccountQueryResponse.fromReader(reader);
} else {
throw new Error(`Unsupported response type: ${queryType}`);
}

View File

@ -0,0 +1,188 @@
import {
afterAll,
beforeAll,
describe,
expect,
jest,
test,
} from "@jest/globals";
import Web3, { ETH_DATA_FORMAT } from "web3";
import axios from "axios";
import base58 from "bs58";
import {
ChainQueryType,
SolanaAccountQueryRequest,
SolanaAccountQueryResponse,
SolanaAccountResult,
PerChainQueryRequest,
QueryRequest,
sign,
QueryResponse,
} from "..";
jest.setTimeout(125000);
const CI = process.env.CI;
const ENV = "DEVNET";
const SERVER_URL = CI ? "http://query-server:" : "http://localhost:";
const CCQ_SERVER_URL = SERVER_URL + "6069/v1";
const QUERY_URL = CCQ_SERVER_URL + "/query";
const PRIVATE_KEY =
"cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0";
const ACCOUNTS = [
"2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ", // Example token in devnet
"BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna", // Example NFT in devnet
];
describe("solana", () => {
test("serialize and deserialize sol_account request with defaults", () => {
const solAccountReq = new SolanaAccountQueryRequest("finalized", ACCOUNTS);
expect(solAccountReq.minContextSlot).toEqual(BigInt(0));
expect(solAccountReq.dataSliceOffset).toEqual(BigInt(0));
expect(solAccountReq.dataSliceLength).toEqual(BigInt(0));
const serialized = solAccountReq.serialize();
expect(Buffer.from(serialized).toString("hex")).toEqual(
"0000000966696e616c697a656400000000000000000000000000000000000000000000000002165809739240a0ac03b98440fe8985548e3aa683cd0d4d9df5b5659669faa3019c006c48c8cbf33849cb07a3f936159cc523f9591cb1999abd45890ec5fee9b7"
);
const solAccountReq2 = SolanaAccountQueryRequest.from(serialized);
expect(solAccountReq2).toEqual(solAccountReq);
});
test("serialize and deserialize sol_account request no defaults", () => {
const minContextSlot = BigInt(123456);
const dataSliceOffset = BigInt(12);
const dataSliceLength = BigInt(100);
const solAccountReq = new SolanaAccountQueryRequest(
"finalized",
ACCOUNTS,
minContextSlot,
dataSliceOffset,
dataSliceLength
);
expect(solAccountReq.minContextSlot).toEqual(minContextSlot);
expect(solAccountReq.dataSliceOffset).toEqual(dataSliceOffset);
expect(solAccountReq.dataSliceLength).toEqual(dataSliceLength);
const serialized = solAccountReq.serialize();
expect(Buffer.from(serialized).toString("hex")).toEqual(
"0000000966696e616c697a6564000000000001e240000000000000000c000000000000006402165809739240a0ac03b98440fe8985548e3aa683cd0d4d9df5b5659669faa3019c006c48c8cbf33849cb07a3f936159cc523f9591cb1999abd45890ec5fee9b7"
);
const solAccountReq2 = SolanaAccountQueryRequest.from(serialized);
expect(solAccountReq2).toEqual(solAccountReq);
});
test("serialize and deserialize sol_account response", () => {
const slotNumber = BigInt(240866260);
const blockTime = BigInt(1704770509);
const blockHash = Uint8Array.from(
Buffer.from(
"9999bac44d09a7f69ee7941819b0a19c59ccb1969640cc513be09ef95ed2d8e3",
"hex"
)
);
const results: SolanaAccountResult[] = [
{
lamports: BigInt(1141440),
rentEpoch: BigInt(123456),
executable: false,
owner: Uint8Array.from(
Buffer.from(
"02a8f6914e88a16e395ae128948ffa695693376818dd47435221f3c600000000",
"hex"
)
),
data: Uint8Array.from(
Buffer.from(
"0200000062d14b7d0e121f8575cce871896548fe26d2899b0578ec92117440cda609b010",
"hex"
)
),
},
{
lamports: BigInt(1141441),
rentEpoch: BigInt(123457),
executable: true,
owner: Uint8Array.from(
Buffer.from(
"02a8f6914e88a16e395ae128948ffa695693376818dd47435221f3c600000000",
"hex"
)
),
data: Uint8Array.from(
Buffer.from(
"0200000083f7752f3b75f905f040f0087c67c47a52272fcfa90e691ea6e8d4362039ecd5",
"hex"
)
),
},
];
const solAccountResp = new SolanaAccountQueryResponse(
slotNumber,
blockTime,
blockHash,
results
);
expect(solAccountResp.slotNumber).toEqual(slotNumber);
expect(solAccountResp.blockTime).toEqual(blockTime);
expect(solAccountResp.results).toEqual(results);
const serialized = solAccountResp.serialize();
expect(Buffer.from(serialized).toString("hex")).toEqual(
"000000000e5b53d400000000659cbbcd9999bac44d09a7f69ee7941819b0a19c59ccb1969640cc513be09ef95ed2d8e3020000000000116ac0000000000001e2400002a8f6914e88a16e395ae128948ffa695693376818dd47435221f3c600000000000000240200000062d14b7d0e121f8575cce871896548fe26d2899b0578ec92117440cda609b0100000000000116ac1000000000001e2410102a8f6914e88a16e395ae128948ffa695693376818dd47435221f3c600000000000000240200000083f7752f3b75f905f040f0087c67c47a52272fcfa90e691ea6e8d4362039ecd5"
);
const solAccountResp2 = SolanaAccountQueryResponse.from(serialized);
expect(solAccountResp2).toEqual(solAccountResp);
});
test("successful sol_account query", async () => {
const solAccountReq = new SolanaAccountQueryRequest("finalized", ACCOUNTS);
const nonce = 42;
const query = new PerChainQueryRequest(1, solAccountReq);
const request = new QueryRequest(nonce, [query]);
const serialized = request.serialize();
const digest = QueryRequest.digest(ENV, serialized);
const signature = sign(PRIVATE_KEY, digest);
const response = await axios.put(
QUERY_URL,
{
signature,
bytes: Buffer.from(serialized).toString("hex"),
},
{ headers: { "X-API-Key": "my_secret_key" } }
);
expect(response.status).toBe(200);
const queryResponse = QueryResponse.from(response.data.bytes);
expect(queryResponse.version).toEqual(1);
expect(queryResponse.requestChainId).toEqual(0);
expect(queryResponse.request.version).toEqual(1);
expect(queryResponse.request.requests.length).toEqual(1);
expect(queryResponse.request.requests[0].chainId).toEqual(1);
expect(queryResponse.request.requests[0].query.type()).toEqual(
ChainQueryType.SolanaAccount
);
const sar = queryResponse.responses[0]
.response as SolanaAccountQueryResponse;
expect(sar.slotNumber).not.toEqual(BigInt(0));
expect(sar.blockTime).not.toEqual(BigInt(0));
expect(sar.results.length).toEqual(2);
expect(sar.results[0].lamports).toEqual(BigInt(1461600));
expect(sar.results[0].rentEpoch).toEqual(BigInt(0));
expect(sar.results[0].executable).toEqual(false);
expect(base58.encode(Buffer.from(sar.results[0].owner))).toEqual(
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
);
expect(Buffer.from(sar.results[0].data).toString("hex")).toEqual(
"01000000574108aed69daf7e625a361864b1f74d13702f2ca56de9660e566d1d8691848d0000e8890423c78a0901000000000000000000000000000000000000000000000000000000000000000000000000"
);
expect(sar.results[1].lamports).toEqual(BigInt(1461600));
expect(sar.results[1].rentEpoch).toEqual(BigInt(0));
expect(sar.results[1].executable).toEqual(false);
expect(base58.encode(Buffer.from(sar.results[1].owner))).toEqual(
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
);
expect(Buffer.from(sar.results[1].data).toString("hex")).toEqual(
"01000000574108aed69daf7e625a361864b1f74d13702f2ca56de9660e566d1d8691848d01000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000"
);
});
});

View File

@ -0,0 +1,187 @@
import { Buffer } from "buffer";
import base58 from "bs58";
import { BinaryWriter } from "./BinaryWriter";
import { HexString } from "./consts";
import { ChainQueryType, ChainSpecificQuery } from "./request";
import { coalesceUint8Array, hexToUint8Array, isValidHexString } from "./utils";
import { BinaryReader } from "./BinaryReader";
import { ChainSpecificResponse } from "./response";
export class SolanaAccountQueryRequest implements ChainSpecificQuery {
commitment: string;
accounts: Uint8Array[];
minContextSlot: bigint;
dataSliceOffset: bigint;
dataSliceLength: bigint;
constructor(
commitment: "finalized",
accounts: string[],
minContextSlot?: bigint,
dataSliceOffset?: bigint,
dataSliceLength?: bigint
) {
this.commitment = commitment;
this.minContextSlot = bigIntWithDef(minContextSlot);
this.dataSliceOffset = bigIntWithDef(dataSliceOffset);
this.dataSliceLength = bigIntWithDef(dataSliceLength);
this.accounts = [];
accounts.forEach((account) => {
if (account.startsWith("0x")) {
// Should be 32 bytes.
if (account.length != 66) {
throw new Error(`Invalid account, must be 32 bytes: ${account}`);
}
this.accounts.push(
Uint8Array.from(Buffer.from(account.substring(2), "hex"))
);
} else {
// Should be base58.
this.accounts.push(Uint8Array.from(base58.decode(account)));
}
});
}
type(): ChainQueryType {
return ChainQueryType.SolanaAccount;
}
serialize(): Uint8Array {
const writer = new BinaryWriter()
.writeUint32(this.commitment.length)
.writeUint8Array(Buffer.from(this.commitment))
.writeUint64(this.minContextSlot)
.writeUint64(this.dataSliceOffset)
.writeUint64(this.dataSliceLength)
.writeUint8(this.accounts.length);
this.accounts.forEach((account) => {
writer.writeUint8Array(account);
});
return writer.data();
}
static from(bytes: string | Uint8Array): SolanaAccountQueryRequest {
const reader = new BinaryReader(coalesceUint8Array(bytes));
return this.fromReader(reader);
}
static fromReader(reader: BinaryReader): SolanaAccountQueryRequest {
const commitmentLength = reader.readUint32();
const commitment = reader.readString(commitmentLength);
if (commitment !== "finalized") {
throw new Error(`Invalid commitment: ${commitment}`);
}
const minContextSlot = reader.readUint64();
const dataSliceOffset = reader.readUint64();
const dataSliceLength = reader.readUint64();
const numAccounts = reader.readUint8();
const accounts: string[] = [];
for (let idx = 0; idx < numAccounts; idx++) {
const account = reader.readUint8Array(32);
// Add the "0x" prefix so the constructor knows it's hex rather than base58.
accounts.push("0x" + Buffer.from(account).toString("hex"));
}
return new SolanaAccountQueryRequest(
commitment,
accounts,
minContextSlot,
dataSliceOffset,
dataSliceLength
);
}
}
export class SolanaAccountQueryResponse implements ChainSpecificResponse {
slotNumber: bigint;
blockTime: bigint;
blockHash: Uint8Array;
results: SolanaAccountResult[];
constructor(
slotNumber: bigint,
blockTime: bigint,
blockHash: Uint8Array,
results: SolanaAccountResult[]
) {
if (blockHash.length != 32) {
throw new Error(
`Invalid block hash, should be 32 bytes long: ${blockHash}`
);
}
for (const result of results) {
if (result.owner.length != 32) {
throw new Error(
`Invalid owner, should be 32 bytes long: ${result.owner}`
);
}
}
this.slotNumber = slotNumber;
this.blockTime = blockTime;
this.blockHash = blockHash;
this.results = results;
}
type(): ChainQueryType {
return ChainQueryType.SolanaAccount;
}
serialize(): Uint8Array {
const writer = new BinaryWriter()
.writeUint64(this.slotNumber)
.writeUint64(this.blockTime)
.writeUint8Array(this.blockHash)
.writeUint8(this.results.length);
for (const result of this.results) {
writer
.writeUint64(result.lamports)
.writeUint64(result.rentEpoch)
.writeUint8(result.executable ? 1 : 0)
.writeUint8Array(result.owner)
.writeUint32(result.data.length)
.writeUint8Array(result.data);
}
return writer.data();
}
static from(bytes: string | Uint8Array): SolanaAccountQueryResponse {
const reader = new BinaryReader(coalesceUint8Array(bytes));
return this.fromReader(reader);
}
static fromReader(reader: BinaryReader): SolanaAccountQueryResponse {
const slotNumber = reader.readUint64();
const blockTime = reader.readUint64();
const blockHash = reader.readUint8Array(32);
const resultsLength = reader.readUint8();
const results: SolanaAccountResult[] = [];
for (let idx = 0; idx < resultsLength; idx++) {
const lamports = reader.readUint64();
const rentEpoch = reader.readUint64();
const executableU8 = reader.readUint8();
const executable = executableU8 != 0;
const owner = reader.readUint8Array(32);
const dataLength = reader.readUint32();
const data = reader.readUint8Array(dataLength);
results.push({ lamports, rentEpoch, executable, owner, data });
}
return new SolanaAccountQueryResponse(
slotNumber,
blockTime,
blockHash,
results
);
}
}
export interface SolanaAccountResult {
lamports: bigint;
rentEpoch: bigint;
executable: boolean;
owner: Uint8Array;
data: Uint8Array;
}
function bigIntWithDef(val: bigint | undefined): bigint {
return BigInt(val !== undefined ? val : BigInt(0));
}

View File

@ -112,6 +112,10 @@ devnet_query_request_0000000000000|
```
#### Solana Support
An experimental implementation of queries for Solana is being added as of January, 2024. This implementation is considered experimental because Solana does not natively support reading account data for a specific slot number, meaning each guardiand watcher will return data for its version of the most recent slot, possibly making it difficult to reach consensus. The plan is to deploy this to mainnet so that we can experiment with various ways to achieve consensus.
### Request Execution
Once the request has been validated, the query module will submit the individual per-chain query requests to the appropriate watchers for execution. The watchers will submit the RPC calls
@ -229,7 +233,9 @@ uint32 query_len
[]byte query_data
```
Currently the only supported query types are `eth_call` and `eth_call_by_timestamp`. This can be expanded to support other protocols.
#### EVM Queries
Currently the supported query types on EVM are `eth_call`, `eth_call_by_timestamp` and `eth_call_with_finality`. This can be expanded to support other protocols.
1. eth_call (query type 1)
@ -289,6 +295,30 @@ Currently the only supported query types are `eth_call` and `eth_call_by_timesta
[]byte batch_call_data
```
#### Solana Queries
Currently the only supported query type on Solana is `sol_account`.
1. sol_account (query type 4) - this query is used to read data for one or more accounts on Solana.
```go
u32 commitment_len
[]byte commitment
u64 min_context_slot
u64 data_slice_offset
u64 data_slice_length
u8 num_accounts
[][32]byte account_list
```
- The `commitment` is required and currently must be `finalized`.
- The `min_context_slot` is optional and specifies the minimum slot at which the request may be evaluated.
- The `data_slice_offset` and `data_slice_length` are optional and specify the portion of the account data that should be returned.
- The `account_list` specifies a list of accounts to be batched into a single call. Each account in the list is a Solana `PublicKey`
## Query Response
- Off-Chain
@ -309,50 +339,87 @@ Currently the only supported query types are `eth_call` and `eth_call_by_timesta
u8 num_per_chain_responses
[]byte per_chain_responses
```
- Per-Chain Responses
```go
u16 chain_id
u8 type
uint32 response_len
[]byte response
```
### Per-Chain Responses
1. eth_call (query type 1) Response Body
All per-chain responses begin with following header.
```go
u64 block_number
[32]byte block_hash
u64 block_time_us
u8 num_results
[]byte results
```
```go
u16 chain_id
u8 type
uint32 response_len
[]byte response
```
```go
u32 result_len
[]byte result
```
#### EVM Query Responses
2. eth_call_by_timestamp (query type 2) Response Body
1. eth_call (query type 1) Response Body
```go
u64 target_block_number
[32]byte target_block_hash
u64 target_block_time_us
u64 following_block_number
[32]byte following_block_hash
u64 following_block_time_us
u8 num_results
[]byte results
```
```go
u64 block_number
[32]byte block_hash
u64 block_time_us
u8 num_results
[]byte results
```
```go
u32 result_len
[]byte result
```
```go
u32 result_len
[]byte result
```
3. eth_call_with_finality (query type 3) Response Body
The response for `eth_call_with_finality` is the same as the response for `eth_call`, although the query type will be three instead of one.
2. eth_call_by_timestamp (query type 2) Response Body
```go
u64 target_block_number
[32]byte target_block_hash
u64 target_block_time_us
u64 following_block_number
[32]byte following_block_hash
u64 following_block_time_us
u8 num_results
[]byte results
```
```go
u32 result_len
[]byte result
```
3. eth_call_with_finality (query type 3) Response Body
The response for `eth_call_with_finality` is the same as the response for `eth_call`, although the query type will be three instead of one.
#### Solana Query Responses
1. sol_account (query type 4) Response Body
```go
u64 slot_number
u64 block_time_us
[32]byte block_hash
u8 num_results
[]byte results
```
- The `slot_number` is the slot number returned by the query.
- The `block_time_us` is the timestamp of the block associated with the slot.
- The `block_hash` is the block hash associated with the slot.
- The array of results array returns the data for each account queried.
```go
u64 lamports
u64 rent_epoch
u8 executable
[32]byte owner
u32 result_len
[]byte result
```
- The `lamports` is the number of lamports assigned to the account.
- The `rent_epoch` is the epoch at which this account will next owe rent.
- The `executable` is a boolean indicating if the account contains a program (and is strictly read-only).
- The `owner` is the public key of the owner of the account.
- The `result` is the data returned by the account query.
## REST Service