ソースを参照

Node/CCQ/Solana: Add sol_pda query (#3782)

* Node/CCQ/Solana: Add sol_pda query

* Attempting to fix bigint serialize error in tests

* Try backing out sol_pda tests

* Put some of solana.test.ts changes back

* Add more stuff back

* Add more stuff to solana.test.ts

* Add more solana.test.ts stuff

* Whatever

* More sol_pda test debugging

* Code review rework

* More rework
bruce-riley 1 年間 前
コミット
c751af3ea3

+ 7 - 0
node/cmd/ccq/devnet.permissions.json

@@ -146,6 +146,13 @@
             "chain": 1,
             "account": "BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna"
           }
+        },
+        {
+          "solPDA": {
+            "note:": "Core Bridge on Devnet",
+            "chain": 1,
+            "programAddress": "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o"
+          }
         }
       ]
     },

+ 35 - 2
node/cmd/ccq/parse_config_test.go

@@ -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", "ethCallWithFinality" or "solAccount"`, err.Error())
+	assert.Equal(t, `unsupported call type for user "Test User", must be "ethCall", "ethCallByTimestamp", "ethCallWithFinality", "solAccount" or "solPDA"`, err.Error())
 }
 
 func TestParseConfigInvalidContractAddress(t *testing.T) {
@@ -295,7 +295,29 @@ func TestParseConfigSuccess(t *testing.T) {
             "contractAddress": "B4FBF271143F4FBf7B91A5ded31805e42b2208d7",
             "call": "0x06fdde03"
           }
-        }			
+        },
+        {
+          "ethCallWithFinality": {
+            "note:": "Decimals of WETH on Devnet",
+            "chain": 2,
+            "contractAddress": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
+            "call": "0x313ce567"
+          }
+        },        
+        {
+          "solAccount": {
+            "note:": "Example NFT on Devnet",
+            "chain": 1,
+            "account": "BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna"
+          }
+        },
+        {
+          "solPDA": {
+            "note:": "Core Bridge on Devnet",
+            "chain": 1,
+            "programAddress": "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o"
+          }
+        }
       ]
     }
   ]
@@ -308,9 +330,20 @@ func TestParseConfigSuccess(t *testing.T) {
 	perm, exists := perms["my_secret_key"]
 	require.True(t, exists)
 
+	assert.Equal(t, 5, len(perm.allowedCalls))
+
 	_, exists = perm.allowedCalls["ethCall:2:000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6:06fdde03"]
 	assert.True(t, exists)
 
 	_, exists = perm.allowedCalls["ethCallByTimestamp:2:000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d7:06fdde03"]
 	assert.True(t, exists)
+
+	_, exists = perm.allowedCalls["ethCallWithFinality:2:000000000000000000000000ddb64fe46a91d46ee29420539fc25fd07c5fea3e:313ce567"]
+	assert.True(t, exists)
+
+	_, exists = perm.allowedCalls["solAccount:1:BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna"]
+	assert.True(t, exists)
+
+	_, exists = perm.allowedCalls["solPDA:1:Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o"]
+	assert.True(t, exists)
 }

+ 28 - 1
node/cmd/ccq/permissions.go

@@ -37,6 +37,7 @@ type (
 		EthCallByTimestamp  *EthCallByTimestamp  `json:"ethCallByTimestamp"`
 		EthCallWithFinality *EthCallWithFinality `json:"ethCallWithFinality"`
 		SolanaAccount       *SolanaAccount       `json:"solAccount"`
+		SolanaPda           *SolanaPda           `json:"solPDA"`
 	}
 
 	EthCall struct {
@@ -62,6 +63,12 @@ type (
 		Account string `json:"account"`
 	}
 
+	SolanaPda struct {
+		Chain          int    `json:"chain"`
+		ProgramAddress string `json:"programAddress"`
+		// As a future enhancement, we may want to specify the allowed seeds.
+	}
+
 	PermissionsMap map[string]*permissionEntry
 
 	permissionEntry struct {
@@ -234,8 +241,28 @@ func parseConfig(byteValue []byte) (PermissionsMap, error) {
 					}
 				}
 				callKey = fmt.Sprintf("solAccount:%d:%s", ac.SolanaAccount.Chain, account)
+			} else if ac.SolanaPda != nil {
+				// We assume the account is base58, but if it starts with "0x" it should be 32 bytes of hex.
+				pa := ac.SolanaPda.ProgramAddress
+				if strings.HasPrefix(pa, "0x") {
+					buf, err := hex.DecodeString(pa[2:])
+					if err != nil {
+						return nil, fmt.Errorf(`invalid solana program address hex string "%s" for user "%s": %w`, pa, user.UserName, err)
+					}
+					if len(buf) != query.SolanaPublicKeyLength {
+						return nil, fmt.Errorf(`invalid solana program address hex string "%s" for user "%s, must be %d bytes`, pa, user.UserName, query.SolanaPublicKeyLength)
+					}
+					pa = solana.PublicKey(buf).String()
+				} else {
+					// Make sure it is valid base58.
+					_, err := solana.PublicKeyFromBase58(pa)
+					if err != nil {
+						return nil, fmt.Errorf(`solana program address string "%s" for user "%s" is not valid base58: %w`, pa, user.UserName, err)
+					}
+				}
+				callKey = fmt.Sprintf("solPDA:%d:%s", ac.SolanaPda.Chain, pa)
 			} else {
-				return nil, fmt.Errorf(`unsupported call type for user "%s", must be "ethCall", "ethCallByTimestamp", "ethCallWithFinality" or "solAccount"`, user.UserName)
+				return nil, fmt.Errorf(`unsupported call type for user "%s", must be "ethCall", "ethCallByTimestamp", "ethCallWithFinality", "solAccount" or "solPDA"`, user.UserName)
 			}
 
 			if callKey == "" {

+ 18 - 0
node/cmd/ccq/utils.go

@@ -112,6 +112,8 @@ func validateRequest(logger *zap.Logger, env common.Environment, perms *Permissi
 			status, err = validateCallData(logger, permsForUser, "ethCallWithFinality", pcq.ChainId, q.CallData)
 		case *query.SolanaAccountQueryRequest:
 			status, err = validateSolanaAccountQuery(logger, permsForUser, "solAccount", pcq.ChainId, q)
+		case *query.SolanaPdaQueryRequest:
+			status, err = validateSolanaPdaQuery(logger, permsForUser, "solPDA", 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()
@@ -171,3 +173,19 @@ func validateSolanaAccountQuery(logger *zap.Logger, permsForUser *permissionEntr
 
 	return http.StatusOK, nil
 }
+
+// validateSolanaPdaQuery performs verification on a Solana sol_account query.
+func validateSolanaPdaQuery(logger *zap.Logger, permsForUser *permissionEntry, callTag string, chainId vaa.ChainID, q *query.SolanaPdaQueryRequest) (int, error) {
+	for _, acct := range q.PDAs {
+		callKey := fmt.Sprintf("%s:%d:%s", callTag, chainId, solana.PublicKey(acct.ProgramAddress).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.StatusForbidden, fmt.Errorf(`call "%s" not authorized`, callKey)
+		}
+
+		totalRequestedCallsByChain.WithLabelValues(chainId.String()).Inc()
+	}
+
+	return http.StatusOK, nil
+}

+ 46 - 17
node/hack/query/send_req.go

@@ -19,6 +19,7 @@ import (
 	gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1"
 	"github.com/certusone/wormhole/node/pkg/query"
 	"github.com/ethereum/go-ethereum/accounts/abi"
+	ethCommon "github.com/ethereum/go-ethereum/common"
 	"github.com/ethereum/go-ethereum/common/hexutil"
 	ethCrypto "github.com/ethereum/go-ethereum/crypto"
 	pubsub "github.com/libp2p/go-libp2p-pubsub"
@@ -124,7 +125,7 @@ func main() {
 	//
 
 	{
-		logger.Info("Running Solana tests")
+		logger.Info("Running Solana account test")
 
 		// Start of query creation...
 		account1, err := solana.PublicKeyFromBase58("Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o")
@@ -142,11 +143,50 @@ func main() {
 			Accounts:        [][query.SolanaPublicKeyLength]byte{account1, account2},
 		}
 
-		queryRequest := createSolanaQueryRequest(callRequest)
+		queryRequest := &query.QueryRequest{
+			Nonce: rand.Uint32(),
+			PerChainQueries: []*query.PerChainQueryRequest{
+				{
+					ChainId: 1,
+					Query:   callRequest,
+				},
+			},
+		}
 		sendSolanaQueryAndGetRsp(queryRequest, sk, th_req, ctx, logger, sub)
+	}
+
+	{
+		logger.Info("Running Solana PDA test")
+
+		// Start of query creation...
+		callRequest := &query.SolanaPdaQueryRequest{
+			Commitment:      "finalized",
+			DataSliceOffset: 0,
+			DataSliceLength: 100,
+			PDAs: []query.SolanaPDAEntry{
+				query.SolanaPDAEntry{
+					ProgramAddress: ethCommon.HexToHash("0x02c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa"), // Devnet core bridge
+					Seeds: [][]byte{
+						[]byte("GuardianSet"),
+						make([]byte, 4),
+					},
+				},
+			},
+		}
 
-		logger.Info("Solana tests complete!")
+		queryRequest := &query.QueryRequest{
+			Nonce: rand.Uint32(),
+			PerChainQueries: []*query.PerChainQueryRequest{
+				{
+					ChainId: 1,
+					Query:   callRequest,
+				},
+			},
+		}
+		sendSolanaQueryAndGetRsp(queryRequest, sk, th_req, ctx, logger, sub)
 	}
+
+	logger.Info("Solana tests complete!")
 	// return
 
 	//
@@ -392,19 +432,6 @@ 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 {
@@ -482,7 +509,9 @@ func sendSolanaQueryAndGetRsp(queryRequest *query.QueryRequest, sk *ecdsa.Privat
 				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))
+						logger.Info("solana account query per chain response", zap.Int("index", index), zap.Any("pcr", r))
+					case *query.SolanaPdaQueryResponse:
+						logger.Info("solana pda 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))
 					}

+ 1 - 2
node/pkg/query/query.go

@@ -433,11 +433,10 @@ func (pcq *perChainQuery) ccqForwardToWatcher(qLogger *zap.Logger, receiveTime t
 	case pcq.channel <- pcq.req:
 		qLogger.Debug("forwarded query request to watcher", zap.String("requestID", pcq.req.RequestID), zap.Stringer("chainID", pcq.req.Request.ChainId))
 		totalRequestsByChain.WithLabelValues(pcq.req.Request.ChainId.String()).Inc()
-		pcq.lastUpdateTime = receiveTime
 	default:
-		// By leaving lastUpdateTime unset, we will retry next interval.
 		qLogger.Warn("failed to send query request to watcher, will retry next interval", zap.String("requestID", pcq.req.RequestID), zap.Stringer("chain_id", pcq.req.Request.ChainId))
 	}
+	pcq.lastUpdateTime = receiveTime
 }
 
 // numPendingRequests returns the number of per chain queries in a request that are still awaiting responses. Zero means the request can now be published.

+ 248 - 7
node/pkg/query/request.go

@@ -138,7 +138,7 @@ type SolanaAccountQueryRequest struct {
 	// 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 is an array of accounts to be queried.
 	Accounts [][SolanaPublicKeyLength]byte
 }
 
@@ -157,6 +157,48 @@ func (saq *SolanaAccountQueryRequest) AccountList() [][SolanaPublicKeyLength]byt
 	return saq.Accounts
 }
 
+// SolanaPdaQueryRequestType is the type of a Solana sol_pda query request.
+const SolanaPdaQueryRequestType ChainSpecificQueryType = 5
+
+// SolanaPdaQueryRequest implements ChainSpecificQuery for a Solana sol_pda query request.
+type SolanaPdaQueryRequest 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.
+	// We would also need to deal with the fact that queries are only handled in the finalized watcher and it does not
+	// have access to the latest confirmed slot needed for MinContextSlot retries.
+	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
+
+	// PDAs is an array of PDAs to be queried.
+	PDAs []SolanaPDAEntry
+}
+
+// SolanaPDAEntry defines a single Solana Program derived address (PDA).
+type SolanaPDAEntry struct {
+	ProgramAddress [SolanaPublicKeyLength]byte
+	Seeds          [][]byte
+}
+
+// According to the spec, there may be at most 16 seeds.
+// https://github.com/gagliardetto/solana-go/blob/6fe3aea02e3660d620433444df033fc3fe6e64c1/keys.go#L559
+const SolanaMaxSeeds = solana.MaxSeeds
+
+// According to the spec, a seed may be at most 32 bytes.
+// https://github.com/gagliardetto/solana-go/blob/6fe3aea02e3660d620433444df033fc3fe6e64c1/keys.go#L557
+const SolanaMaxSeedLen = solana.MaxSeedLength
+
+func (spda *SolanaPdaQueryRequest) PDAList() []SolanaPDAEntry {
+	return spda.PDAs
+}
+
 // PerChainQueryInternal is an internal representation of a query request that is passed to the watcher.
 type PerChainQueryInternal struct {
 	RequestID  string
@@ -192,6 +234,16 @@ func PostSignedQueryRequest(signedQueryReqSendC chan<- *gossipv1.SignedQueryRequ
 	}
 }
 
+func SignedQueryRequestEqual(left *gossipv1.SignedQueryRequest, right *gossipv1.SignedQueryRequest) bool {
+	if !bytes.Equal(left.QueryRequest, right.QueryRequest) {
+		return false
+	}
+	if !bytes.Equal(left.Signature, right.Signature) {
+		return false
+	}
+	return true
+}
+
 //
 // Implementation of QueryRequest.
 //
@@ -382,6 +434,12 @@ func (perChainQuery *PerChainQueryRequest) UnmarshalFromReader(reader *bytes.Rea
 			return fmt.Errorf("failed to unmarshal solana account query request: %w", err)
 		}
 		perChainQuery.Query = &q
+	case SolanaPdaQueryRequestType:
+		q := SolanaPdaQueryRequest{}
+		if err := q.UnmarshalFromReader(reader); err != nil {
+			return fmt.Errorf("failed to unmarshal solana PDA query request: %w", err)
+		}
+		perChainQuery.Query = &q
 	default:
 		return fmt.Errorf("unsupported query type: %d", queryType)
 	}
@@ -411,6 +469,14 @@ func (perChainQuery *PerChainQueryRequest) Validate() error {
 	return nil
 }
 
+func ValidatePerChainQueryRequestType(qt ChainSpecificQueryType) error {
+	if qt != EthCallQueryRequestType && qt != EthCallByTimestampQueryRequestType && qt != EthCallWithFinalityQueryRequestType &&
+		qt != SolanaAccountQueryRequestType && qt != SolanaPdaQueryRequestType {
+		return fmt.Errorf("invalid query request type: %d", qt)
+	}
+	return nil
+}
+
 // Equal verifies that two query requests are equal.
 func (left *PerChainQueryRequest) Equal(right *PerChainQueryRequest) bool {
 	if left.ChainId != right.ChainId {
@@ -458,6 +524,13 @@ func (left *PerChainQueryRequest) Equal(right *PerChainQueryRequest) bool {
 		default:
 			panic("unsupported query type on right, must be sol_account")
 		}
+	case *SolanaPdaQueryRequest:
+		switch rightQuery := right.Query.(type) {
+		case *SolanaPdaQueryRequest:
+			return leftQuery.Equal(rightQuery)
+		default:
+			panic("unsupported query type on right, must be sol_pda")
+		}
 	default:
 		panic("unsupported query type on left")
 	}
@@ -1052,19 +1125,187 @@ func (left *SolanaAccountQueryRequest) Equal(right *SolanaAccountQueryRequest) b
 	return true
 }
 
-func ValidatePerChainQueryRequestType(qt ChainSpecificQueryType) error {
-	if qt != EthCallQueryRequestType && qt != EthCallByTimestampQueryRequestType && qt != EthCallWithFinalityQueryRequestType && qt != SolanaAccountQueryRequestType {
-		return fmt.Errorf("invalid query request type: %d", qt)
+//
+// Implementation of SolanaPdaQueryRequest, which implements the ChainSpecificQuery interface.
+//
+
+func (e *SolanaPdaQueryRequest) Type() ChainSpecificQueryType {
+	return SolanaPdaQueryRequestType
+}
+
+// Marshal serializes the binary representation of a Solana sol_pda request.
+// This method calls Validate() and relies on it to range checks lengths, etc.
+func (spda *SolanaPdaQueryRequest) Marshal() ([]byte, error) {
+	if err := spda.Validate(); err != nil {
+		return nil, err
+	}
+
+	buf := new(bytes.Buffer)
+
+	vaa.MustWrite(buf, binary.BigEndian, uint32(len(spda.Commitment)))
+	buf.Write([]byte(spda.Commitment))
+
+	vaa.MustWrite(buf, binary.BigEndian, spda.MinContextSlot)
+	vaa.MustWrite(buf, binary.BigEndian, spda.DataSliceOffset)
+	vaa.MustWrite(buf, binary.BigEndian, spda.DataSliceLength)
+
+	vaa.MustWrite(buf, binary.BigEndian, uint8(len(spda.PDAs)))
+	for _, pda := range spda.PDAs {
+		buf.Write(pda.ProgramAddress[:])
+		vaa.MustWrite(buf, binary.BigEndian, uint8(len(pda.Seeds)))
+		for _, seed := range pda.Seeds {
+			vaa.MustWrite(buf, binary.BigEndian, uint32(len(seed)))
+			buf.Write(seed)
+		}
+	}
+	return buf.Bytes(), nil
+}
+
+// Unmarshal deserializes a Solana sol_pda query from a byte array
+func (spda *SolanaPdaQueryRequest) Unmarshal(data []byte) error {
+	reader := bytes.NewReader(data[:])
+	return spda.UnmarshalFromReader(reader)
+}
+
+// UnmarshalFromReader  deserializes a Solana sol_pda query from a byte array
+func (spda *SolanaPdaQueryRequest) 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)
+	}
+	spda.Commitment = string(commitment)
+
+	if err := binary.Read(reader, binary.BigEndian, &spda.MinContextSlot); err != nil {
+		return fmt.Errorf("failed to read min slot: %w", err)
+	}
+
+	if err := binary.Read(reader, binary.BigEndian, &spda.DataSliceOffset); err != nil {
+		return fmt.Errorf("failed to read data slice offset: %w", err)
+	}
+
+	if err := binary.Read(reader, binary.BigEndian, &spda.DataSliceLength); err != nil {
+		return fmt.Errorf("failed to read data slice length: %w", err)
+	}
+
+	numPDAs := uint8(0)
+	if err := binary.Read(reader, binary.BigEndian, &numPDAs); err != nil {
+		return fmt.Errorf("failed to read number of PDAs: %w", err)
+	}
+
+	for count := 0; count < int(numPDAs); count++ {
+		programAddress := [SolanaPublicKeyLength]byte{}
+		if n, err := reader.Read(programAddress[:]); err != nil || n != SolanaPublicKeyLength {
+			return fmt.Errorf("failed to read program address [%d]: %w", n, err)
+		}
+
+		pda := SolanaPDAEntry{ProgramAddress: programAddress}
+		numSeeds := uint8(0)
+		if err := binary.Read(reader, binary.BigEndian, &numSeeds); err != nil {
+			return fmt.Errorf("failed to read number of seeds: %w", err)
+		}
+
+		for count := 0; count < int(numSeeds); count++ {
+			seedLen := uint32(0)
+			if err := binary.Read(reader, binary.BigEndian, &seedLen); err != nil {
+				return fmt.Errorf("failed to read call Data len: %w", err)
+			}
+			seed := make([]byte, seedLen)
+			if n, err := reader.Read(seed[:]); err != nil || n != int(seedLen) {
+				return fmt.Errorf("failed to read seed [%d]: %w", n, err)
+			}
+
+			pda.Seeds = append(pda.Seeds, seed)
+		}
+
+		spda.PDAs = append(spda.PDAs, pda)
 	}
+
 	return nil
 }
 
-func SignedQueryRequestEqual(left *gossipv1.SignedQueryRequest, right *gossipv1.SignedQueryRequest) bool {
-	if !bytes.Equal(left.QueryRequest, right.QueryRequest) {
+// Validate does basic validation on a Solana sol_pda query.
+func (spda *SolanaPdaQueryRequest) Validate() error {
+	if len(spda.Commitment) > SolanaMaxCommitmentLength {
+		return fmt.Errorf("commitment too long")
+	}
+	if spda.Commitment != "finalized" {
+		return fmt.Errorf(`commitment must be "finalized"`)
+	}
+
+	if spda.DataSliceLength == 0 && spda.DataSliceOffset != 0 {
+		return fmt.Errorf("data slice offset may not be set if data slice length is zero")
+	}
+
+	if len(spda.PDAs) <= 0 {
+		return fmt.Errorf("does not contain any PDAs entries")
+	}
+	if len(spda.PDAs) > SolanaMaxAccountsPerQuery {
+		return fmt.Errorf("too many PDA entries, may not be more than %d", SolanaMaxAccountsPerQuery)
+	}
+	for _, pda := range spda.PDAs {
+		// The program address is fixed length, so don't need to check for nil.
+		if len(pda.ProgramAddress) != SolanaPublicKeyLength {
+			return fmt.Errorf("invalid program address length")
+		}
+
+		if len(pda.Seeds) == 0 {
+			return fmt.Errorf("PDA does not contain any seeds")
+		}
+
+		if len(pda.Seeds) > SolanaMaxSeeds {
+			return fmt.Errorf("PDA contains too many seeds")
+		}
+
+		for _, seed := range pda.Seeds {
+			if len(seed) == 0 {
+				return fmt.Errorf("seed is null")
+			}
+
+			if len(seed) > SolanaMaxSeedLen {
+				return fmt.Errorf("seed is too long")
+			}
+		}
+	}
+
+	return nil
+}
+
+// Equal verifies that two Solana sol_pda queries are equal.
+func (left *SolanaPdaQueryRequest) Equal(right *SolanaPdaQueryRequest) bool {
+	if left.Commitment != right.Commitment ||
+		left.MinContextSlot != right.MinContextSlot ||
+		left.DataSliceOffset != right.DataSliceOffset ||
+		left.DataSliceLength != right.DataSliceLength {
 		return false
 	}
-	if !bytes.Equal(left.Signature, right.Signature) {
+
+	if len(left.PDAs) != len(right.PDAs) {
 		return false
 	}
+	for idx := range left.PDAs {
+		if !bytes.Equal(left.PDAs[idx].ProgramAddress[:], right.PDAs[idx].ProgramAddress[:]) {
+			return false
+		}
+
+		if len(left.PDAs[idx].Seeds) != len(right.PDAs[idx].Seeds) {
+			return false
+		}
+
+		for idx2 := range left.PDAs[idx].Seeds {
+			if !bytes.Equal(left.PDAs[idx].Seeds[idx2][:], right.PDAs[idx].Seeds[idx2][:]) {
+				return false
+			}
+		}
+	}
+
 	return true
 }

+ 59 - 1
node/pkg/query/request_test.go

@@ -787,7 +787,65 @@ func TestSolanaPublicKeyLengthIsAsExpected(t *testing.T) {
 	require.Equal(t, 32, SolanaPublicKeyLength)
 }
 
-///////////// End of Solana Account Query tests ///////////////////////////
+///////////// Solana PDA Query tests /////////////////////////////////
+
+func TestSolanaSeedConstsAreAsExpected(t *testing.T) {
+	// It might break the spec if these ever changes!
+	require.Equal(t, 16, SolanaMaxSeeds)
+	require.Equal(t, 32, SolanaMaxSeedLen)
+}
+
+func createSolanaPdaQueryRequestForTesting(t *testing.T) *QueryRequest {
+	t.Helper()
+
+	callRequest1 := &SolanaPdaQueryRequest{
+		Commitment: "finalized",
+		PDAs: []SolanaPDAEntry{
+			SolanaPDAEntry{
+				ProgramAddress: ethCommon.HexToHash("0x02c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa"), // Devnet core bridge
+				Seeds: [][]byte{
+					[]byte("GuardianSet"),
+					make([]byte, 4),
+				},
+			},
+		},
+	}
+
+	perChainQuery1 := &PerChainQueryRequest{
+		ChainId: vaa.ChainIDSolana,
+		Query:   callRequest1,
+	}
+
+	queryRequest := &QueryRequest{
+		Nonce:           1,
+		PerChainQueries: []*PerChainQueryRequest{perChainQuery1},
+	}
+
+	return queryRequest
+}
+
+func TestSolanaPdaQueryRequestMarshalUnmarshal(t *testing.T) {
+	queryRequest := createSolanaPdaQueryRequestForTesting(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 TestSolanaPdaQueryUnmarshalFromSDK(t *testing.T) {
+	serialized, err := hex.DecodeString("010000002b010001050000005e0000000966696e616c697a656400000000000008ff000000000000000c00000000000000140102c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa020000000b477561726469616e5365740000000400000000")
+	require.NoError(t, err)
+
+	var solQuery QueryRequest
+	err = solQuery.Unmarshal(serialized)
+	require.NoError(t, err)
+}
+
+///////////// End of Solana PDA Query tests ///////////////////////////
 
 func TestPostSignedQueryRequestShouldFailIfNoOneIsListening(t *testing.T) {
 	queryRequest := createQueryRequestForTesting(t, vaa.ChainIDPolygon)

+ 213 - 0
node/pkg/query/response.go

@@ -138,6 +138,43 @@ type SolanaAccountResult struct {
 	Data []byte
 }
 
+// SolanaPdaQueryResponse implements ChainSpecificResponse for a Solana sol_pda query response.
+type SolanaPdaQueryResponse struct {
+	// SlotNumber is the slot number returned by the sol_pda 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 []SolanaPdaResult
+}
+
+type SolanaPdaResult struct {
+	// Account is the public key of the account derived from the PDA.
+	Account [SolanaPublicKeyLength]byte
+
+	// Bump is the bump value returned by the solana derivation function.
+	Bump uint8
+
+	// 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_pda query.
+	Data []byte
+}
+
 //
 // Implementation of QueryResponsePublication.
 //
@@ -413,6 +450,12 @@ func (perChainResponse *PerChainQueryResponse) UnmarshalFromReader(reader *bytes
 			return fmt.Errorf("failed to unmarshal sol_account response: %w", err)
 		}
 		perChainResponse.Response = &r
+	case SolanaPdaQueryRequestType:
+		r := SolanaPdaQueryResponse{}
+		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)
 	}
@@ -489,6 +532,13 @@ func (left *PerChainQueryResponse) Equal(right *PerChainQueryResponse) bool {
 		default:
 			panic("unsupported query type on right") // We checked this above!
 		}
+	case *SolanaPdaQueryResponse:
+		switch rightResp := right.Response.(type) {
+		case *SolanaPdaQueryResponse:
+			return leftResp.Equal(rightResp)
+		default:
+			panic("unsupported query type on right") // We checked this above!
+		}
 	default:
 		panic("unsupported query type on left") // We checked this above!
 	}
@@ -1042,3 +1092,166 @@ func (left *SolanaAccountQueryResponse) Equal(right *SolanaAccountQueryResponse)
 
 	return true
 }
+
+//
+// Implementation of SolanaPdaQueryResponse, which implements the ChainSpecificResponse for a Solana sol_pda query response.
+//
+
+func (sar *SolanaPdaQueryResponse) Type() ChainSpecificQueryType {
+	return SolanaPdaQueryRequestType
+}
+
+// Marshal serializes the binary representation of a Solana sol_pda response.
+// This method calls Validate() and relies on it to range check lengths, etc.
+func (sar *SolanaPdaQueryResponse) 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 {
+		buf.Write(res.Account[:])
+		vaa.MustWrite(buf, binary.BigEndian, res.Bump)
+		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_pda response from a byte array
+func (sar *SolanaPdaQueryResponse) Unmarshal(data []byte) error {
+	reader := bytes.NewReader(data[:])
+	return sar.UnmarshalFromReader(reader)
+}
+
+// UnmarshalFromReader  deserializes a Solana sol_pda response from a byte array
+func (sar *SolanaPdaQueryResponse) 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 SolanaPdaResult
+
+		if n, err := reader.Read(result.Account[:]); err != nil || n != SolanaPublicKeyLength {
+			return fmt.Errorf("failed to read account [%d]: %w", n, err)
+		}
+
+		if err := binary.Read(reader, binary.BigEndian, &result.Bump); err != nil {
+			return fmt.Errorf("failed to read bump: %w", err)
+		}
+
+		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_pda response.
+func (sar *SolanaPdaQueryResponse) 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 {
+		// Account is fixed length, so don't need to check for nil.
+		if len(result.Account) != SolanaPublicKeyLength {
+			return fmt.Errorf("invalid account length")
+		}
+		// 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_pda responses are equal.
+func (left *SolanaPdaQueryResponse) Equal(right *SolanaPdaQueryResponse) 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 !bytes.Equal(left.Results[idx].Account[:], right.Results[idx].Account[:]) ||
+			left.Results[idx].Bump != right.Results[idx].Bump ||
+			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
+}

+ 65 - 1
node/pkg/query/response_test.go

@@ -325,4 +325,68 @@ func TestSolanaAccountQueryResponseMarshalUnmarshal(t *testing.T) {
 	assert.True(t, respPub.Equal(&respPub2))
 }
 
-///////////// End of Solana Account Query tests ///////////////////////////
+///////////// Solana PDA Query tests /////////////////////////////////
+
+func createSolanaPdaQueryResponseFromRequest(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 *SolanaPdaQueryRequest:
+			results := []SolanaPdaResult{}
+			for idx := range req.PDAs {
+				results = append(results, SolanaPdaResult{
+					Account:    ethCommon.HexToHash("4fa9188b339cfd573a0778c5deaeeee94d4bcfb12b345bf8e417e5119dae773e"),
+					Bump:       uint8(255 - idx),
+					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: &SolanaPdaQueryResponse{
+					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 TestSolanaPdaQueryResponseMarshalUnmarshal(t *testing.T) {
+	queryRequest := createSolanaPdaQueryRequestForTesting(t)
+	respPub := createSolanaPdaQueryResponseFromRequest(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 PDA Query tests ///////////////////////////

+ 192 - 38
node/pkg/watchers/solana/ccq.go

@@ -5,6 +5,7 @@ import (
 	"encoding/hex"
 	"encoding/json"
 	"errors"
+	"fmt"
 	"strconv"
 	"time"
 
@@ -28,9 +29,8 @@ const (
 	CCQ_FAST_RETRY_INTERVAL = 200 * time.Millisecond
 )
 
-// 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)
+// ccqSendQueryResponse sends a response back to the query handler.
+func (w *SolanaWatcher) ccqSendQueryResponse(queryResponse *query.PerChainQueryResponseInternal) {
 	select {
 	case w.queryResponseC <- queryResponse:
 		w.ccqLogger.Debug("published query response to handler")
@@ -39,9 +39,14 @@ func (w *SolanaWatcher) ccqSendQueryResponse(req *query.PerChainQueryInternal, s
 	}
 }
 
+// ccqSendErrorResponse creates an error query response and sends it back to the query handler. It sets the response field to nil.
+func (w *SolanaWatcher) ccqSendErrorResponse(req *query.PerChainQueryInternal, status query.QueryStatus) {
+	queryResponse := query.CreatePerChainQueryResponseInternal(req.RequestID, req.RequestIdx, req.Request.ChainId, status, nil)
+	w.ccqSendQueryResponse(queryResponse)
+}
+
 // 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 {
@@ -50,33 +55,40 @@ func (w *SolanaWatcher) ccqHandleQuery(ctx context.Context, queryRequest *query.
 
 	start := time.Now()
 
+	giveUpTime := start.Add(query.RetryInterval).Add(-CCQ_RETRY_SLOP)
 	switch req := queryRequest.Request.Query.(type) {
 	case *query.SolanaAccountQueryRequest:
-		giveUpTime := start.Add(query.RetryInterval).Add(-CCQ_RETRY_SLOP)
-		w.ccqHandleSolanaAccountQueryRequest(ctx, queryRequest, req, giveUpTime, false)
+		w.ccqHandleSolanaAccountQueryRequest(ctx, queryRequest, req, giveUpTime)
+	case *query.SolanaPdaQueryRequest:
+		w.ccqHandleSolanaPdaQueryRequest(ctx, queryRequest, req, giveUpTime)
 	default:
 		w.ccqLogger.Warn("received unsupported request type",
 			zap.Uint8("payload", uint8(queryRequest.Request.Query.Type())),
 		)
-		w.ccqSendQueryResponse(queryRequest, query.QueryFatalError, nil)
+		w.ccqSendErrorResponse(queryRequest, query.QueryFatalError)
 	}
 
 	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, giveUpTime time.Time, isRetry bool) {
-	requestId := "sol_account:" + queryRequest.ID()
-	if !isRetry {
-		w.ccqLogger.Info("received a sol_account query",
-			zap.Uint64("minContextSlot", req.MinContextSlot),
-			zap.Uint64("dataSliceOffset", req.DataSliceOffset),
-			zap.Uint64("dataSliceLength", req.DataSliceLength),
-			zap.Int("numAccounts", len(req.Accounts)),
-			zap.String("requestId", requestId),
-		)
-	}
+// ccqCustomPublisher is an interface used by ccqBaseHandleSolanaAccountQueryRequest to specify how to publish the response from a query.
+type ccqCustomPublisher interface {
+	// publish should take a sol_account query response and publish it as the appropriate response type.
+	publish(*query.PerChainQueryResponseInternal, *query.SolanaAccountQueryResponse)
+}
 
+// ccqBaseHandleSolanaAccountQueryRequest is the base Solana Account query handler. It does the actual account queries, and if necessary does fast retries
+// until the minimum context slot is reached. It does not publish the response, but instead invokes the query specific publisher that is passed in.
+func (w *SolanaWatcher) ccqBaseHandleSolanaAccountQueryRequest(
+	ctx context.Context,
+	queryRequest *query.PerChainQueryInternal,
+	req *query.SolanaAccountQueryRequest,
+	giveUpTime time.Time,
+	tag string,
+	requestId string,
+	isRetry bool,
+	publisher ccqCustomPublisher,
+) {
 	rCtx, cancel := context.WithTimeout(ctx, rpcTimeout)
 	defer cancel()
 
@@ -106,18 +118,18 @@ func (w *SolanaWatcher) ccqHandleSolanaAccountQueryRequest(ctx context.Context,
 	// Read the accounts.
 	info, err := w.getMultipleAccountsWithOpts(rCtx, accounts, &params)
 	if err != nil {
-		if w.ccqCheckForMinSlotContext(ctx, queryRequest, req, requestId, err, giveUpTime, !isRetry) {
+		if w.ccqCheckForMinSlotContext(ctx, queryRequest, req, requestId, err, giveUpTime, !isRetry, tag, publisher) {
 			// Return without posting a response because a go routine was created to handle it.
 			return
 		}
-		w.ccqLogger.Error("read failed for sol_account query request",
+		w.ccqLogger.Error(fmt.Sprintf("read failed for %s query request", tag),
 			zap.String("requestId", requestId),
 			zap.Any("accounts", accounts),
 			zap.Any("params", params),
 			zap.Error(err),
 		)
 
-		w.ccqSendQueryResponse(queryRequest, query.QueryRetryNeeded, nil)
+		w.ccqSendErrorResponse(queryRequest, query.QueryRetryNeeded)
 		return
 	}
 
@@ -130,36 +142,36 @@ func (w *SolanaWatcher) ccqHandleSolanaAccountQueryRequest(ctx context.Context,
 		MaxSupportedTransactionVersion: &maxSupportedTransactionVersion,
 	})
 	if err != nil {
-		w.ccqLogger.Error("failed to read block time for sol_account query request",
+		w.ccqLogger.Error(fmt.Sprintf("failed to read block time for %s query request", tag),
 			zap.String("requestId", requestId),
 			zap.Uint64("slotNumber", info.Context.Slot),
 			zap.Error(err),
 		)
 
-		w.ccqSendQueryResponse(queryRequest, query.QueryRetryNeeded, nil)
+		w.ccqSendErrorResponse(queryRequest, query.QueryRetryNeeded)
 		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)
+		w.ccqLogger.Error(fmt.Sprintf("read for %s query request returned nil info", tag), zap.String("requestId", requestId))
+		w.ccqSendErrorResponse(queryRequest, query.QueryFatalError)
 		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)
+		w.ccqLogger.Error(fmt.Sprintf("read for %s query request returned nil value", tag), zap.String("requestId", requestId))
+		w.ccqSendErrorResponse(queryRequest, query.QueryFatalError)
 		return
 	}
 
 	if len(info.Value) != len(req.Accounts) {
-		w.ccqLogger.Error("read for sol_account query request returned unexpected number of results",
+		w.ccqLogger.Error(fmt.Sprintf("read for %s query request returned unexpected number of results", tag),
 			zap.String("requestId", requestId),
 			zap.Int("numAccounts", len(req.Accounts)),
 			zap.Int("numValues", len(info.Value)),
 		)
 
-		w.ccqSendQueryResponse(queryRequest, query.QueryFatalError, nil)
+		w.ccqSendErrorResponse(queryRequest, query.QueryFatalError)
 		return
 	}
 
@@ -167,13 +179,13 @@ func (w *SolanaWatcher) ccqHandleSolanaAccountQueryRequest(ctx context.Context,
 	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)
+			w.ccqLogger.Error(fmt.Sprintf("read of account for %s query request failed, val is nil", tag), zap.String("requestId", requestId), zap.Any("account", req.Accounts[idx]))
+			w.ccqSendErrorResponse(queryRequest, query.QueryFatalError)
 			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)
+			w.ccqLogger.Error(fmt.Sprintf("read of account for %s query request failed, data is nil", tag), zap.String("requestId", requestId), zap.Any("account", req.Accounts[idx]))
+			w.ccqSendErrorResponse(queryRequest, query.QueryFatalError)
 			return
 		}
 		results = append(results, query.SolanaAccountResult{
@@ -193,7 +205,7 @@ func (w *SolanaWatcher) ccqHandleSolanaAccountQueryRequest(ctx context.Context,
 		Results:    results,
 	}
 
-	w.ccqLogger.Info("account read for sol_account_query succeeded",
+	w.ccqLogger.Info(fmt.Sprintf("account read for %s query succeeded", tag),
 		zap.String("requestId", requestId),
 		zap.Uint64("slotNumber", info.Context.Slot),
 		zap.Uint64("blockTime", uint64(*block.BlockTime)),
@@ -201,7 +213,8 @@ func (w *SolanaWatcher) ccqHandleSolanaAccountQueryRequest(ctx context.Context,
 		zap.Uint64("blockHeight", *block.BlockHeight),
 	)
 
-	w.ccqSendQueryResponse(queryRequest, query.QuerySuccess, resp)
+	// Publish the response using the custom publisher.
+	publisher.publish(query.CreatePerChainQueryResponseInternal(queryRequest.RequestID, queryRequest.RequestIdx, queryRequest.Request.ChainId, query.QuerySuccess, resp), resp)
 }
 
 // ccqCheckForMinSlotContext checks to see if the returned error was due to the min context slot not being reached.
@@ -216,6 +229,8 @@ func (w *SolanaWatcher) ccqCheckForMinSlotContext(
 	err error,
 	giveUpTime time.Time,
 	log bool,
+	tag string,
+	publisher ccqCustomPublisher,
 ) bool {
 	if req.MinContextSlot == 0 {
 		return false
@@ -254,7 +269,7 @@ func (w *SolanaWatcher) ccqCheckForMinSlotContext(
 	}
 
 	// Kick off the retry after a short delay.
-	go w.ccqSleepAndRetryAccountQuery(ctx, queryRequest, req, requestId, currentSlot, currentSlotFromError, giveUpTime, log)
+	go w.ccqSleepAndRetryAccountQuery(ctx, queryRequest, req, requestId, currentSlot, currentSlotFromError, giveUpTime, log, tag, publisher)
 	return true
 }
 
@@ -268,6 +283,8 @@ func (w *SolanaWatcher) ccqSleepAndRetryAccountQuery(
 	currentSlotFromError uint64,
 	giveUpTime time.Time,
 	log bool,
+	tag string,
+	publisher ccqCustomPublisher,
 ) {
 	if log {
 		w.ccqLogger.Info("minimum context slot has not been reached, will retry shortly",
@@ -285,7 +302,7 @@ func (w *SolanaWatcher) ccqSleepAndRetryAccountQuery(
 		w.ccqLogger.Info("initiating fast retry", zap.String("requestId", requestId))
 	}
 
-	w.ccqHandleSolanaAccountQueryRequest(ctx, queryRequest, req, giveUpTime, true)
+	w.ccqBaseHandleSolanaAccountQueryRequest(ctx, queryRequest, req, giveUpTime, tag, requestId, true, publisher)
 }
 
 // ccqIsMinContextSlotError parses an error to see if it is "Minimum context slot has not been reached". If it is, it returns the slot number
@@ -331,6 +348,143 @@ func ccqIsMinContextSlotError(err error) (bool, uint64) {
 	return true, currentSlot
 }
 
+// ccqHandleSolanaAccountQueryRequest is the query handler for a sol_account request.
+func (w *SolanaWatcher) ccqHandleSolanaAccountQueryRequest(ctx context.Context, queryRequest *query.PerChainQueryInternal, req *query.SolanaAccountQueryRequest, giveUpTime time.Time) {
+	requestId := "sol_account" + ":" + queryRequest.ID()
+	w.ccqLogger.Info("received a sol_account query",
+		zap.Uint64("minContextSlot", req.MinContextSlot),
+		zap.Uint64("dataSliceOffset", req.DataSliceOffset),
+		zap.Uint64("dataSliceLength", req.DataSliceLength),
+		zap.Int("numAccounts", len(req.Accounts)),
+		zap.String("requestId", requestId),
+	)
+
+	publisher := ccqSolanaAccountPublisher{w}
+	w.ccqBaseHandleSolanaAccountQueryRequest(ctx, queryRequest, req, giveUpTime, "sol_account", requestId, false, publisher)
+}
+
+// ccqSolanaAccountPublisher is the publisher for the sol_account query. All it has to do is forward the response passed in to the watcher, as is.
+type ccqSolanaAccountPublisher struct {
+	w *SolanaWatcher
+}
+
+func (impl ccqSolanaAccountPublisher) publish(resp *query.PerChainQueryResponseInternal, _ *query.SolanaAccountQueryResponse) {
+	impl.w.ccqSendQueryResponse(resp)
+}
+
+// ccqHandleSolanaPdaQueryRequest is the query handler for a sol_pda request.
+func (w *SolanaWatcher) ccqHandleSolanaPdaQueryRequest(ctx context.Context, queryRequest *query.PerChainQueryInternal, req *query.SolanaPdaQueryRequest, giveUpTime time.Time) {
+	requestId := "sol_pda:" + queryRequest.ID()
+	w.ccqLogger.Info("received a sol_pda query",
+		zap.Uint64("minContextSlot", req.MinContextSlot),
+		zap.Uint64("dataSliceOffset", req.DataSliceOffset),
+		zap.Uint64("dataSliceLength", req.DataSliceLength),
+		zap.Int("numPdas", len(req.PDAs)),
+		zap.String("requestId", requestId),
+	)
+
+	// Derive the list of accounts from the PDAs and save those along with the bumps.
+	accounts := make([][query.SolanaPublicKeyLength]byte, 0, len(req.PDAs))
+	bumps := make([]uint8, 0, len(req.PDAs))
+	for _, pda := range req.PDAs {
+		account, bump, err := solana.FindProgramAddress(pda.Seeds, pda.ProgramAddress)
+		if err != nil {
+			w.ccqLogger.Error("failed to derive account from pda for sol_pda query",
+				zap.String("requestId", requestId),
+				zap.String("programAddress", hex.EncodeToString(pda.ProgramAddress[:])),
+				zap.Any("seeds", pda.Seeds),
+				zap.Error(err),
+			)
+
+			w.ccqSendErrorResponse(queryRequest, query.QueryFatalError)
+			return
+		}
+
+		accounts = append(accounts, account)
+		bumps = append(bumps, bump)
+	}
+
+	// Build a standard sol_account query using the derived accounts.
+	acctReq := &query.SolanaAccountQueryRequest{
+		Commitment:      req.Commitment,
+		MinContextSlot:  req.MinContextSlot,
+		DataSliceOffset: req.DataSliceOffset,
+		DataSliceLength: req.DataSliceLength,
+		Accounts:        accounts,
+	}
+
+	publisher := ccqPdaPublisher{
+		w:            w,
+		queryRequest: queryRequest,
+		requestId:    requestId,
+		accounts:     accounts,
+		bumps:        bumps,
+	}
+
+	// Execute the standard sol_account query passing in the publisher to publish a sol_pda response.
+	w.ccqBaseHandleSolanaAccountQueryRequest(ctx, queryRequest, acctReq, giveUpTime, "sol_pda", requestId, false, publisher)
+}
+
+// ccqPdaPublisher is a custom publisher that publishes a sol_pda response.
+type ccqPdaPublisher struct {
+	w            *SolanaWatcher
+	queryRequest *query.PerChainQueryInternal
+	requestId    string
+	accounts     [][query.SolanaPublicKeyLength]byte
+	bumps        []uint8
+}
+
+func (pub ccqPdaPublisher) publish(pcrResp *query.PerChainQueryResponseInternal, acctResp *query.SolanaAccountQueryResponse) {
+	if pcrResp == nil {
+		pub.w.ccqLogger.Error("sol_pda query failed, pcrResp is nil", zap.String("requestId", pub.requestId))
+		pub.w.ccqSendErrorResponse(pub.queryRequest, query.QueryFatalError)
+		return
+	}
+
+	if pcrResp.Status != query.QuerySuccess {
+		// publish() should only get called in success cases.
+		pub.w.ccqLogger.Error("received an unexpected query response for sol_pda query", zap.String("requestId", pub.requestId), zap.Any("pcrResp", pcrResp))
+		pub.w.ccqSendErrorResponse(pub.queryRequest, query.QueryFatalError)
+		return
+	}
+
+	if acctResp == nil {
+		pub.w.ccqLogger.Error("sol_pda query failed, acctResp is nil", zap.String("requestId", pub.requestId))
+		pub.w.ccqSendErrorResponse(pub.queryRequest, query.QueryFatalError)
+		return
+	}
+
+	if len(acctResp.Results) != len(pub.accounts) {
+		pub.w.ccqLogger.Error("sol_pda query failed, unexpected number of results", zap.String("requestId", pub.requestId), zap.Int("numResults", len(acctResp.Results)), zap.Int("expectedResults", len(pub.accounts)))
+		pub.w.ccqSendErrorResponse(pub.queryRequest, query.QueryFatalError)
+		return
+	}
+
+	// Build the PDA response from the base response.
+	results := make([]query.SolanaPdaResult, 0, len(pub.accounts))
+	for idx, acctResult := range acctResp.Results {
+		results = append(results, query.SolanaPdaResult{
+			Account:    pub.accounts[idx],
+			Bump:       pub.bumps[idx],
+			Lamports:   acctResult.Lamports,
+			RentEpoch:  acctResult.RentEpoch,
+			Executable: acctResult.Executable,
+			Owner:      acctResult.Owner,
+			Data:       acctResult.Data,
+		})
+	}
+
+	resp := &query.SolanaPdaQueryResponse{
+		SlotNumber: acctResp.SlotNumber,
+		BlockTime:  acctResp.BlockTime,
+		BlockHash:  acctResp.BlockHash,
+		Results:    results,
+	}
+
+	// Finally, publish the result.
+	pub.w.ccqSendQueryResponse(query.CreatePerChainQueryResponseInternal(pub.queryRequest.RequestID, pub.queryRequest.RequestIdx, pub.queryRequest.Request.ChainId, query.QuerySuccess, resp))
+}
+
 type M map[string]interface{}
 
 // getMultipleAccountsWithOpts is a work-around for the fact that the library call doesn't honor MinContextSlot.

ファイルの差分が大きいため隠しています
+ 617 - 15
sdk/js-query/package-lock.json


+ 1 - 0
sdk/js-query/package.json

@@ -34,6 +34,7 @@
   "sideEffects": false,
   "dependencies": {
     "@ethersproject/keccak256": "^5.7.0",
+    "@solana/web3.js": "^1.66.2",
     "@types/elliptic": "^6.4.14",
     "axios": "^1.6.7",
     "bs58": "^4.0.1",

+ 112 - 0
sdk/js-query/src/mock/index.ts

@@ -17,9 +17,14 @@ import {
   SolanaAccountQueryRequest,
   SolanaAccountQueryResponse,
   SolanaAccountResult,
+  SolanaPdaQueryRequest,
+  SolanaPdaQueryResponse,
+  SolanaPdaResult,
 } from "../query";
 import { BinaryWriter } from "../query/BinaryWriter";
 
+import { PublicKey } from "@solana/web3.js";
+
 // (2**64)-1
 const SOLANA_MAX_RENT_EPOCH = BigInt("18446744073709551615");
 
@@ -485,6 +490,113 @@ export class QueryProxyMock {
             )
           )
         );
+      } else if (type === ChainQueryType.SolanaPda) {
+        const query = perChainRequest.query as SolanaPdaQueryRequest;
+        // Validate the request and convert the PDAs into accounts.
+        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.pdas.length <= 0) {
+          throw new Error(`does not contain any account entries`);
+        }
+        if (query.pdas.length > 255) {
+          throw new Error(`too many account entries`);
+        }
+
+        let accounts: string[] = [];
+        let bumps: number[] = [];
+        query.pdas.forEach((pda) => {
+          if (pda.programAddress.length != 32) {
+            throw new Error(`invalid program address length`);
+          }
+
+          const [acct, bump] = PublicKey.findProgramAddressSync(
+            pda.seeds,
+            new PublicKey(pda.programAddress)
+          );
+          accounts.push(acct.toString());
+          bumps.push(bump);
+        });
+
+        let opts: SolanaGetMultipleAccountsOpts = {
+          commitment: query.commitment,
+        };
+        if (query.minContextSlot != BigInt(0)) {
+          opts.minContextSlot = Number(query.minContextSlot);
+        }
+        if (query.dataSliceLength !== BigInt(0)) {
+          opts.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: SolanaPdaResult[] = [];
+        let idx = 0;
+        response.data.result.value.forEach((val) => {
+          results.push({
+            account: Uint8Array.from(base58.decode(accounts[idx].toString())),
+            bump: bumps[idx],
+            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")
+            ),
+          });
+          idx += 1;
+        });
+
+        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 SolanaPdaQueryResponse(
+              BigInt(slotNumber),
+              BigInt(blockTime) * BigInt(1000000), // time in seconds -> microseconds,
+              blockHash,
+              results
+            )
+          )
+        );
       } else {
         throw new Error(`Unsupported query type for mock: ${type}`);
       }

+ 49 - 0
sdk/js-query/src/mock/mock.test.ts

@@ -7,6 +7,7 @@ import {
   test,
 } from "@jest/globals";
 import axios from "axios";
+import base58 from "bs58";
 import { eth } from "web3";
 import {
   EthCallByTimestampQueryRequest,
@@ -19,6 +20,9 @@ import {
   QueryResponse,
   SolanaAccountQueryRequest,
   SolanaAccountQueryResponse,
+  SolanaPdaEntry,
+  SolanaPdaQueryRequest,
+  SolanaPdaQueryResponse,
 } from "..";
 
 jest.setTimeout(120000);
@@ -28,6 +32,18 @@ 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";
 
+const SOL_PDAS: SolanaPdaEntry[] = [
+  {
+    programAddress: Uint8Array.from(
+      base58.decode("Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o")
+    ), // Core Bridge address
+    seeds: [
+      new Uint8Array(Buffer.from("GuardianSet")),
+      new Uint8Array(Buffer.alloc(4)),
+    ], // Use index zero in tilt.
+  },
+];
+
 let mock: QueryProxyMock;
 
 beforeAll(() => {
@@ -341,4 +357,37 @@ describe.skip("mocks match testnet", () => {
       "000000574108aed69daf"
     );
   });
+  test("SolanaPda to devnet", async () => {
+    const query = new QueryRequest(42, [
+      new PerChainQueryRequest(
+        1,
+        new SolanaPdaQueryRequest(
+          "finalized",
+          SOL_PDAS,
+          BigInt(0),
+          BigInt(12),
+          BigInt(16) // After this, things can change.
+        )
+      ),
+    ]);
+    const resp = await mock.mock(query);
+    const queryResponse = QueryResponse.from(resp.bytes);
+    const sar = queryResponse.responses[0].response as SolanaPdaQueryResponse;
+    expect(sar.blockTime).not.toEqual(BigInt(0));
+    expect(sar.results.length).toEqual(1);
+
+    expect(Buffer.from(sar.results[0].account).toString("hex")).toEqual(
+      "4fa9188b339cfd573a0778c5deaeeee94d4bcfb12b345bf8e417e5119dae773e"
+    );
+    expect(sar.results[0].bump).toEqual(253);
+    expect(sar.results[0].lamports).toEqual(BigInt(1141440));
+    expect(sar.results[0].rentEpoch).toEqual(BigInt(0));
+    expect(sar.results[0].executable).toEqual(false);
+    expect(Buffer.from(sar.results[0].owner).toString("hex")).toEqual(
+      "02c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa"
+    );
+    expect(Buffer.from(sar.results[0].data).toString("hex")).toEqual(
+      "57cd18b7f8a4d91a2da9ab4af05d0fbe"
+    );
+  });
 });

+ 1 - 0
sdk/js-query/src/query/index.ts

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

+ 4 - 0
sdk/js-query/src/query/request.ts

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

+ 3 - 0
sdk/js-query/src/query/response.ts

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

+ 193 - 12
sdk/js-query/src/query/solana.test.ts

@@ -14,6 +14,9 @@ import {
   SolanaAccountQueryRequest,
   SolanaAccountQueryResponse,
   SolanaAccountResult,
+  SolanaPdaEntry,
+  SolanaPdaQueryRequest,
+  SolanaPdaQueryResponse,
   PerChainQueryRequest,
   QueryRequest,
   sign,
@@ -27,7 +30,9 @@ 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 SOLANA_NODE_URL = CI ? "http://solana-devnet:8899" : "http://localhost:8899";
+const SOLANA_NODE_URL = CI
+  ? "http://solana-devnet:8899"
+  : "http://localhost:8899";
 
 const PRIVATE_KEY =
   "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0";
@@ -37,6 +42,18 @@ const ACCOUNTS = [
   "BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna", // Example NFT in devnet
 ];
 
+const PDAS: SolanaPdaEntry[] = [
+  {
+    programAddress: Uint8Array.from(
+      base58.decode("Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o")
+    ), // Core Bridge address
+    seeds: [
+      new Uint8Array(Buffer.from("GuardianSet")),
+      new Uint8Array(Buffer.alloc(4)),
+    ], // Use index zero in tilt.
+  },
+];
+
 async function getSolanaSlot(comm: string): Promise<bigint> {
   const response = await axios.post(SOLANA_NODE_URL, {
     jsonrpc: "2.0",
@@ -173,12 +190,12 @@ describe("solana", () => {
 
     const sar = queryResponse.responses[0]
       .response as SolanaAccountQueryResponse;
-    expect(sar.slotNumber).not.toEqual(BigInt(0));
-    expect(sar.blockTime).not.toEqual(BigInt(0));
+    expect(Number(sar.slotNumber)).not.toEqual(0);
+    expect(Number(sar.blockTime)).not.toEqual(0);
     expect(sar.results.length).toEqual(2);
 
-    expect(sar.results[0].lamports).toEqual(BigInt(1461600));
-    expect(sar.results[0].rentEpoch).toEqual(BigInt(0));
+    expect(Number(sar.results[0].lamports)).toEqual(1461600);
+    expect(Number(sar.results[0].rentEpoch)).toEqual(0);
     expect(sar.results[0].executable).toEqual(false);
     expect(base58.encode(Buffer.from(sar.results[0].owner))).toEqual(
       "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
@@ -187,8 +204,8 @@ describe("solana", () => {
       "01000000574108aed69daf7e625a361864b1f74d13702f2ca56de9660e566d1d8691848d0000e8890423c78a0901000000000000000000000000000000000000000000000000000000000000000000000000"
     );
 
-    expect(sar.results[1].lamports).toEqual(BigInt(1461600));
-    expect(sar.results[1].rentEpoch).toEqual(BigInt(0));
+    expect(Number(sar.results[1].lamports)).toEqual(1461600);
+    expect(Number(sar.results[1].rentEpoch)).toEqual(0);
     expect(sar.results[1].executable).toEqual(false);
     expect(base58.encode(Buffer.from(sar.results[1].owner))).toEqual(
       "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
@@ -234,11 +251,11 @@ describe("solana", () => {
     const sar = queryResponse.responses[0]
       .response as SolanaAccountQueryResponse;
     expect(sar.slotNumber).toEqual(minContextSlot);
-    expect(sar.blockTime).not.toEqual(BigInt(0));
+    expect(Number(sar.blockTime)).not.toEqual(0);
     expect(sar.results.length).toEqual(2);
 
-    expect(sar.results[0].lamports).toEqual(BigInt(1461600));
-    expect(sar.results[0].rentEpoch).toEqual(BigInt(0));
+    expect(Number(sar.results[0].lamports)).toEqual(1461600);
+    expect(Number(sar.results[0].rentEpoch)).toEqual(0);
     expect(sar.results[0].executable).toEqual(false);
     expect(base58.encode(Buffer.from(sar.results[0].owner))).toEqual(
       "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
@@ -247,8 +264,8 @@ describe("solana", () => {
       "01000000574108aed69daf7e625a361864b1f74d13702f2ca56de9660e566d1d8691848d0000e8890423c78a0901000000000000000000000000000000000000000000000000000000000000000000000000"
     );
 
-    expect(sar.results[1].lamports).toEqual(BigInt(1461600));
-    expect(sar.results[1].rentEpoch).toEqual(BigInt(0));
+    expect(Number(sar.results[1].lamports)).toEqual(1461600);
+    expect(Number(sar.results[1].rentEpoch)).toEqual(0);
     expect(sar.results[1].executable).toEqual(false);
     expect(base58.encode(Buffer.from(sar.results[1].owner))).toEqual(
       "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
@@ -257,4 +274,168 @@ describe("solana", () => {
       "01000000574108aed69daf7e625a361864b1f74d13702f2ca56de9660e566d1d8691848d01000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000"
     );
   });
+  test("serialize and deserialize sol_pda request with defaults", () => {
+    const solPdaReq = new SolanaPdaQueryRequest(
+      "finalized",
+      PDAS,
+      BigInt(123456),
+      BigInt(12),
+      BigInt(20)
+    );
+    expect(Number(solPdaReq.minContextSlot)).toEqual(123456);
+    expect(Number(solPdaReq.dataSliceOffset)).toEqual(12);
+    expect(Number(solPdaReq.dataSliceLength)).toEqual(20);
+    const serialized = solPdaReq.serialize();
+    expect(Buffer.from(serialized).toString("hex")).toEqual(
+      "0000000966696e616c697a6564000000000001e240000000000000000c00000000000000140102c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa020000000b477561726469616e5365740000000400000000"
+    );
+    const solPdaReq2 = SolanaPdaQueryRequest.from(serialized);
+    expect(solPdaReq2).toEqual(solPdaReq);
+  });
+
+  test("deserialize sol_pda response", () => {
+    const respBytes = Buffer.from(
+      "0100000c8418d81c00aad6283ba3eb30e141ccdd9296e013ca44e5cc713418921253004b93107ba0d858a548ce989e2bca4132e4c2f9a57a9892e3a87a8304cdb36d8f000000006b010000002b010001050000005e0000000966696e616c697a656400000000000008ff000000000000000c00000000000000140102c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa020000000b477561726469616e5365740000000400000000010001050000009b00000000000008ff0006115e3f6d7540e05035785e15056a8559815e71343ce31db2abf23f65b19c982b68aee7bf207b014fa9188b339cfd573a0778c5deaeeee94d4bcfb12b345bf8e417e5119dae773efd0000000000116ac000000000000000000002c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa0000001457cd18b7f8a4d91a2da9ab4af05d0fbece2dcd65",
+      "hex"
+    );
+    const queryResponse = QueryResponse.from(respBytes);
+    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.SolanaPda
+    );
+
+    const sar = queryResponse.responses[0].response as SolanaPdaQueryResponse;
+
+    expect(Number(sar.slotNumber)).toEqual(2303);
+    expect(Number(sar.blockTime)).toEqual(0x0006115e3f6d7540);
+    expect(sar.results.length).toEqual(1);
+
+    expect(Buffer.from(sar.results[0].account).toString("hex")).toEqual(
+      "4fa9188b339cfd573a0778c5deaeeee94d4bcfb12b345bf8e417e5119dae773e"
+    );
+    expect(sar.results[0].bump).toEqual(253);
+    expect(Number(sar.results[0].lamports)).not.toEqual(0);
+    expect(Number(sar.results[0].rentEpoch)).toEqual(0);
+    expect(sar.results[0].executable).toEqual(false);
+    expect(Buffer.from(sar.results[0].owner).toString("hex")).toEqual(
+      "02c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa"
+    );
+    expect(Buffer.from(sar.results[0].data).toString("hex")).toEqual(
+      "57cd18b7f8a4d91a2da9ab4af05d0fbece2dcd65"
+    );
+  });
+  test("successful sol_pda query", async () => {
+    const solPdaReq = new SolanaPdaQueryRequest(
+      "finalized",
+      PDAS,
+      BigInt(0),
+      BigInt(12),
+      BigInt(16) // After this, things can change.
+    );
+    const nonce = 43;
+    const query = new PerChainQueryRequest(1, solPdaReq);
+    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.SolanaPda
+    );
+
+    const sar = queryResponse.responses[0].response as SolanaPdaQueryResponse;
+
+    expect(Number(sar.slotNumber)).not.toEqual(0);
+    expect(Number(sar.blockTime)).not.toEqual(0);
+    expect(sar.results.length).toEqual(1);
+
+    expect(Buffer.from(sar.results[0].account).toString("hex")).toEqual(
+      "4fa9188b339cfd573a0778c5deaeeee94d4bcfb12b345bf8e417e5119dae773e"
+    );
+    expect(sar.results[0].bump).toEqual(253);
+    expect(Number(sar.results[0].lamports)).not.toEqual(0);
+
+    expect(Number(sar.results[0].rentEpoch)).toEqual(0);
+    expect(sar.results[0].executable).toEqual(false);
+    expect(Buffer.from(sar.results[0].owner).toString("hex")).toEqual(
+      "02c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa"
+    );
+    expect(Buffer.from(sar.results[0].data).toString("hex")).toEqual(
+      "57cd18b7f8a4d91a2da9ab4af05d0fbe"
+    );
+  });
+  test("successful sol_pda query with future min context slot", async () => {
+    const currSlot = await getSolanaSlot("finalized");
+    const minContextSlot = BigInt(currSlot) + BigInt(10);
+    const solPdaReq = new SolanaPdaQueryRequest(
+      "finalized",
+      PDAS,
+      minContextSlot,
+      BigInt(12),
+      BigInt(16) // After this, things can change.
+    );
+    const nonce = 43;
+    const query = new PerChainQueryRequest(1, solPdaReq);
+    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.SolanaPda
+    );
+
+    const sar = queryResponse.responses[0].response as SolanaPdaQueryResponse;
+    expect(sar.slotNumber).toEqual(minContextSlot);
+    expect(Number(sar.blockTime)).not.toEqual(0);
+    expect(sar.results.length).toEqual(1);
+
+    expect(Buffer.from(sar.results[0].account).toString("hex")).toEqual(
+      "4fa9188b339cfd573a0778c5deaeeee94d4bcfb12b345bf8e417e5119dae773e"
+    );
+    expect(sar.results[0].bump).toEqual(253);
+    expect(Number(sar.results[0].lamports)).not.toEqual(0);
+    expect(Number(sar.results[0].rentEpoch)).toEqual(0);
+    expect(sar.results[0].executable).toEqual(false);
+    expect(Buffer.from(sar.results[0].owner).toString("hex")).toEqual(
+      "02c806312cbe5b79ef8aa6c17e3f423d8fdfe1d46909fb1f6cdf65ee8e2e6faa"
+    );
+    expect(Buffer.from(sar.results[0].data).toString("hex")).toEqual(
+      "57cd18b7f8a4d91a2da9ab4af05d0fbe"
+    );
+  });
 });

+ 1 - 6
sdk/js-query/src/query/solanaAccount.ts

@@ -1,9 +1,8 @@
 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 { bigIntWithDef, coalesceUint8Array } from "./utils";
 import { BinaryReader } from "./BinaryReader";
 import { ChainSpecificResponse } from "./response";
 
@@ -181,7 +180,3 @@ export interface SolanaAccountResult {
   owner: Uint8Array;
   data: Uint8Array;
 }
-
-function bigIntWithDef(val: bigint | undefined): bigint {
-  return BigInt(val !== undefined ? val : BigInt(0));
-}

+ 243 - 0
sdk/js-query/src/query/solanaPda.ts

@@ -0,0 +1,243 @@
+import { Buffer } from "buffer";
+import { BinaryWriter } from "./BinaryWriter";
+import { ChainQueryType, ChainSpecificQuery } from "./request";
+import { bigIntWithDef, coalesceUint8Array } from "./utils";
+import { BinaryReader } from "./BinaryReader";
+import { ChainSpecificResponse } from "./response";
+
+export interface SolanaPdaEntry {
+  programAddress: Uint8Array;
+  seeds: Uint8Array[];
+}
+
+// According to the spec, there may be at most 16 seeds.
+// https://github.com/gagliardetto/solana-go/blob/6fe3aea02e3660d620433444df033fc3fe6e64c1/keys.go#L559
+export const SolanaMaxSeeds = 16;
+
+// According to the spec, a seed may be at most 32 bytes.
+// https://github.com/gagliardetto/solana-go/blob/6fe3aea02e3660d620433444df033fc3fe6e64c1/keys.go#L557
+export const SolanaMaxSeedLen = 32;
+
+export class SolanaPdaQueryRequest implements ChainSpecificQuery {
+  commitment: string;
+  minContextSlot: bigint;
+  dataSliceOffset: bigint;
+  dataSliceLength: bigint;
+
+  constructor(
+    commitment: "finalized",
+    public pdas: SolanaPdaEntry[],
+    minContextSlot?: bigint,
+    dataSliceOffset?: bigint,
+    dataSliceLength?: bigint
+  ) {
+    pdas.forEach((pda) => {
+      if (pda.programAddress.length != 32) {
+        throw new Error(
+          `Invalid program address, must be 32 bytes: ${pda.programAddress}`
+        );
+      }
+      if (pda.seeds.length == 0) {
+        throw new Error(
+          `Invalid pda, has no seeds: ${Buffer.from(
+            pda.programAddress
+          ).toString("hex")}`
+        );
+      }
+      if (pda.seeds.length > SolanaMaxSeeds) {
+        throw new Error(
+          `Invalid pda, has too many seeds: ${Buffer.from(
+            pda.programAddress
+          ).toString("hex")}`
+        );
+      }
+      pda.seeds.forEach((seed) => {
+        if (seed.length == 0) {
+          throw new Error(
+            `Invalid pda, seed is null: ${Buffer.from(
+              pda.programAddress
+            ).toString("hex")}`
+          );
+        }
+        if (seed.length > SolanaMaxSeedLen) {
+          throw new Error(
+            `Invalid pda, seed is too long: ${Buffer.from(
+              pda.programAddress
+            ).toString("hex")}`
+          );
+        }
+      });
+    });
+
+    this.commitment = commitment;
+    this.minContextSlot = bigIntWithDef(minContextSlot);
+    this.dataSliceOffset = bigIntWithDef(dataSliceOffset);
+    this.dataSliceLength = bigIntWithDef(dataSliceLength);
+  }
+
+  type(): ChainQueryType {
+    return ChainQueryType.SolanaPda;
+  }
+
+  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.pdas.length);
+    this.pdas.forEach((pda) => {
+      writer.writeUint8Array(pda.programAddress).writeUint8(pda.seeds.length);
+      pda.seeds.forEach((seed) => {
+        writer.writeUint32(seed.length).writeUint8Array(seed);
+      });
+    });
+    return writer.data();
+  }
+
+  static from(bytes: string | Uint8Array): SolanaPdaQueryRequest {
+    const reader = new BinaryReader(coalesceUint8Array(bytes));
+    return this.fromReader(reader);
+  }
+
+  static fromReader(reader: BinaryReader): SolanaPdaQueryRequest {
+    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 numPdas = reader.readUint8();
+    const pdas: SolanaPdaEntry[] = [];
+    for (let idx = 0; idx < numPdas; idx++) {
+      const programAddress = reader.readUint8Array(32);
+      let seeds: Uint8Array[] = [];
+      const numSeeds = reader.readUint8();
+      for (let idx2 = 0; idx2 < numSeeds; idx2++) {
+        const seedLen = reader.readUint32();
+        const seed = reader.readUint8Array(seedLen);
+        seeds.push(seed);
+      }
+      pdas.push({ programAddress, seeds });
+    }
+    return new SolanaPdaQueryRequest(
+      commitment,
+      pdas,
+      minContextSlot,
+      dataSliceOffset,
+      dataSliceLength
+    );
+  }
+}
+
+export class SolanaPdaQueryResponse implements ChainSpecificResponse {
+  slotNumber: bigint;
+  blockTime: bigint;
+  blockHash: Uint8Array;
+  results: SolanaPdaResult[];
+
+  constructor(
+    slotNumber: bigint,
+    blockTime: bigint,
+    blockHash: Uint8Array,
+    results: SolanaPdaResult[]
+  ) {
+    if (blockHash.length != 32) {
+      throw new Error(
+        `Invalid block hash, should be 32 bytes long: ${blockHash}`
+      );
+    }
+    for (const result of results) {
+      if (result.account.length != 32) {
+        throw new Error(
+          `Invalid account, should be 32 bytes long: ${result.account}`
+        );
+      }
+      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.SolanaPda;
+  }
+
+  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
+        .writeUint8Array(result.account)
+        .writeUint8(result.bump)
+        .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): SolanaPdaQueryResponse {
+    const reader = new BinaryReader(coalesceUint8Array(bytes));
+    return this.fromReader(reader);
+  }
+
+  static fromReader(reader: BinaryReader): SolanaPdaQueryResponse {
+    const slotNumber = reader.readUint64();
+    const blockTime = reader.readUint64();
+    const blockHash = reader.readUint8Array(32);
+    const resultsLength = reader.readUint8();
+    const results: SolanaPdaResult[] = [];
+    for (let idx = 0; idx < resultsLength; idx++) {
+      const account = reader.readUint8Array(32);
+      const bump = reader.readUint8();
+      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({
+        account,
+        bump,
+        lamports,
+        rentEpoch,
+        executable,
+        owner,
+        data,
+      });
+    }
+    return new SolanaPdaQueryResponse(
+      slotNumber,
+      blockTime,
+      blockHash,
+      results
+    );
+  }
+}
+
+export interface SolanaPdaResult {
+  account: Uint8Array;
+  bump: number;
+  lamports: bigint;
+  rentEpoch: bigint;
+  executable: boolean;
+  owner: Uint8Array;
+  data: Uint8Array;
+}

+ 8 - 0
sdk/js-query/src/query/utils.ts

@@ -48,3 +48,11 @@ export function sign(key: string, data: Uint8Array): string {
     Buffer.from([signature.recoveryParam ?? 0]).toString("hex");
   return packed;
 }
+
+/**
+ * @param val value to be converted to a big int
+ * @returns the value or zero as a bigint
+ */
+export function bigIntWithDef(val: bigint | undefined): bigint {
+  return BigInt(val !== undefined ? val : BigInt(0));
+}

+ 71 - 2
whitepapers/0013_ccq.md

@@ -54,7 +54,7 @@ CCQ will run as an optional component in `guardiand`. If it is not configured, i
 
 The request format is extensible in order to support querying data across heterogeneous chains and batching of requests to minimize gossip traffic and RPC overhead.
 
-The initial release of CCQ will only support EVM chains. However, the software is extensible to other chains, such as Solana, CosmWasm, etc.
+The current release of CCQ, as of February 2024, supports EVM chains and Solana. However, the software is extensible to other chains, such as CosmWasm, etc.
 
 #### Off-Chain Requests
 
@@ -317,7 +317,42 @@ Currently the only supported query type on Solana is `sol_account`.
 
    - 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`
+   - The `account_list` specifies a list of accounts to be batched into a single query. Each account in the list is a Solana `PublicKey`
+
+2. sol_pda (query type 5) - this query is used to read data for one or more accounts on Solana based on their Program Derived Addresses.
+
+   ```go
+   u32         commitment_len
+   []byte      commitment
+   u64         min_context_slot
+   u64         data_slice_offset
+   u64         data_slice_length
+   u8          num_pdas
+   []PdaList   pda_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 `pda_list` specifies a list of program derived addresses batched into a single query.
+
+     `PdaList` is defined as follows:
+
+     ```go
+     [32]byte      program_address
+     u8            num_seeds
+     []Seed        seed_data (max of 16, per the Solana code)
+     ```
+
+     Each `Seed` is defined as follows:
+
+     ```go
+     u32           seed_len (max of 32, per the Solana code)
+     []byte        seed
+     ```
 
 ## Query Response
 
@@ -421,6 +456,40 @@ uint32  response_len
    - The `owner` is the public key of the owner of the account.
    - The `result` is the data returned by the account query.
 
+2. sol_pda (query type 5) 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 `results` array returns the data for each PDA queried
+
+   ```go
+   [32]byte    account
+   u8          bump
+   u64         lamports
+   u64         rent_epoch
+   u8          executable
+   [32]byte    owner
+   u32         result_len
+   []byte      result
+   ```
+
+   - The `account` is the account address derived from the PDA.
+   - The `bump` is the bump value returned by the Solana derivation function.
+   - 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
 
 ### Request

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません