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
This commit is contained in:
bruce-riley 2024-03-08 11:57:24 -06:00 committed by GitHub
parent 66f8e85158
commit c751af3ea3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2242 additions and 111 deletions

View File

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

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", "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)
}

View File

@ -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 == "" {

View File

@ -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
}

View File

@ -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("Solana tests complete!")
}
{
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),
},
},
},
}
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))
}

View File

@ -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.

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -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 ///////////////////////////

View File

@ -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.

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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}`);
}

View File

@ -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"
);
});
});

View File

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

View File

@ -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,
}

View File

@ -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}`);
}

View File

@ -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"
);
});
});

View File

@ -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));
}

View File

@ -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;
}

View File

@ -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));
}

View File

@ -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