Skip to content

Commit

Permalink
Node/CCQ: Solana query support (#3637)
Browse files Browse the repository at this point in the history
* Node/CCQ: Solana query support

* Add mock stuff

* Add mock stuff

* Code review rework

* Code review rework

* Only allow "finalized", not "confirmed"

* Code review rework

* Change SolanaAccount query type to 4

* Code review rework

* Fix sdk tests
  • Loading branch information
bruce-riley authored Jan 23, 2024
1 parent 7acbacd commit 59dff67
Show file tree
Hide file tree
Showing 26 changed files with 1,734 additions and 115 deletions.
14 changes: 14 additions & 0 deletions node/cmd/ccq/devnet.permissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@
"contractAddress": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
"call": "0x313ce567"
}
},
{
"solAccount": {
"note:": "Example token on Devnet",
"chain": 1,
"account": "2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ"
}
},
{
"solAccount": {
"note:": "Example NFT on Devnet",
"chain": 1,
"account": "BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna"
}
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion node/cmd/ccq/parse_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func TestParseConfigUnsupportedCallType(t *testing.T) {

_, err := parseConfig([]byte(str))
require.Error(t, err)
assert.Equal(t, `unsupported call type for user "Test User", must be "ethCall", "ethCallByTimestamp" or "ethCallWithFinality"`, err.Error())
assert.Equal(t, `unsupported call type for user "Test User", must be "ethCall", "ethCallByTimestamp", "ethCallWithFinality" or "solAccount"`, err.Error())
}

func TestParseConfigInvalidContractAddress(t *testing.T) {
Expand Down
64 changes: 47 additions & 17 deletions node/cmd/ccq/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import (
"sync"

"github.com/certusone/wormhole/node/pkg/common"
"github.com/certusone/wormhole/node/pkg/query"
"github.com/wormhole-foundation/wormhole/sdk/vaa"
"go.uber.org/zap"

"github.com/gagliardetto/solana-go"
"gopkg.in/godo.v2/watcher/fswatch"
)

Expand All @@ -33,6 +35,7 @@ type (
EthCall *EthCall `json:"ethCall"`
EthCallByTimestamp *EthCallByTimestamp `json:"ethCallByTimestamp"`
EthCallWithFinality *EthCallWithFinality `json:"ethCallWithFinality"`
SolanaAccount *SolanaAccount `json:"solAccount"`
}

EthCall struct {
Expand All @@ -53,6 +56,11 @@ type (
Call string `json:"call"`
}

SolanaAccount struct {
Chain int `json:"chain"`
Account string `json:"account"`
}

PermissionsMap map[string]*permissionEntry

permissionEntry struct {
Expand Down Expand Up @@ -187,7 +195,7 @@ func parseConfig(byteValue []byte) (PermissionsMap, error) {
allowedCalls := make(allowedCallsForUser)
for _, ac := range user.AllowedCalls {
var chain int
var callType, contractAddressStr, callStr string
var callType, contractAddressStr, callStr, callKey string
// var contractAddressStr string
if ac.EthCall != nil {
callType = "ethCall"
Expand All @@ -204,27 +212,49 @@ func parseConfig(byteValue []byte) (PermissionsMap, error) {
chain = ac.EthCallWithFinality.Chain
contractAddressStr = ac.EthCallWithFinality.ContractAddress
callStr = ac.EthCallWithFinality.Call
} else if ac.SolanaAccount != nil {
// We assume the account is base58, but if it starts with "0x" it should be 32 bytes of hex.
account := ac.SolanaAccount.Account
if strings.HasPrefix(account, "0x") {
buf, err := hex.DecodeString(account[2:])
if err != nil {
return nil, fmt.Errorf(`invalid solana account hex string "%s" for user "%s": %w`, account, user.UserName, err)
}
if len(buf) != query.SolanaPublicKeyLength {
return nil, fmt.Errorf(`invalid solana account hex string "%s" for user "%s, must be %d bytes`, account, user.UserName, query.SolanaPublicKeyLength)
}
account = solana.PublicKey(buf).String()
} else {
// Make sure it is valid base58.
_, err := solana.PublicKeyFromBase58(account)
if err != nil {
return nil, fmt.Errorf(`solana account string "%s" for user "%s" is not valid base58: %w`, account, user.UserName, err)
}
}
callKey = fmt.Sprintf("solAccount:%d:%s", ac.SolanaAccount.Chain, account)
} else {
return nil, fmt.Errorf(`unsupported call type for user "%s", must be "ethCall", "ethCallByTimestamp" or "ethCallWithFinality"`, user.UserName)
return nil, fmt.Errorf(`unsupported call type for user "%s", must be "ethCall", "ethCallByTimestamp", "ethCallWithFinality" or "solAccount"`, user.UserName)
}

// Convert the contract address into a standard format like "000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6".
contractAddress, err := vaa.StringToAddress(contractAddressStr)
if err != nil {
return nil, fmt.Errorf(`invalid contract address "%s" for user "%s"`, contractAddressStr, user.UserName)
}
if callKey == "" {
// Convert the contract address into a standard format like "000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6".
contractAddress, err := vaa.StringToAddress(contractAddressStr)
if err != nil {
return nil, fmt.Errorf(`invalid contract address "%s" for user "%s"`, contractAddressStr, user.UserName)
}

// The call should be the ABI four byte hex hash of the function signature. Parse it into a standard form of "06fdde03".
call, err := hex.DecodeString(strings.TrimPrefix(callStr, "0x"))
if err != nil {
return nil, fmt.Errorf(`invalid eth call "%s" for user "%s"`, callStr, user.UserName)
}
if len(call) != ETH_CALL_SIG_LENGTH {
return nil, fmt.Errorf(`eth call "%s" for user "%s" has an invalid length, must be %d bytes`, callStr, user.UserName, ETH_CALL_SIG_LENGTH)
}
// The call should be the ABI four byte hex hash of the function signature. Parse it into a standard form of "06fdde03".
call, err := hex.DecodeString(strings.TrimPrefix(callStr, "0x"))
if err != nil {
return nil, fmt.Errorf(`invalid eth call "%s" for user "%s"`, callStr, user.UserName)
}
if len(call) != ETH_CALL_SIG_LENGTH {
return nil, fmt.Errorf(`eth call "%s" for user "%s" has an invalid length, must be %d bytes`, callStr, user.UserName, ETH_CALL_SIG_LENGTH)
}

// The permission key is the chain, contract address and call formatted as a colon separated string.
callKey := fmt.Sprintf("%s:%d:%s:%s", callType, chain, contractAddress, hex.EncodeToString(call))
// The permission key is the chain, contract address and call formatted as a colon separated string.
callKey = fmt.Sprintf("%s:%d:%s:%s", callType, chain, contractAddress, hex.EncodeToString(call))
}

if _, exists := allowedCalls[callKey]; exists {
return nil, fmt.Errorf(`"%s" is a duplicate allowed call for user "%s"`, callKey, user.UserName)
Expand Down
22 changes: 21 additions & 1 deletion node/cmd/ccq/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (
ethCrypto "github.com/ethereum/go-ethereum/crypto"
ethClient "github.com/ethereum/go-ethereum/ethclient"
ethRpc "github.com/ethereum/go-ethereum/rpc"

"github.com/gagliardetto/solana-go"
)

func FetchCurrentGuardianSet(rpcUrl, coreAddr string) (*common.GuardianSet, error) {
Expand Down Expand Up @@ -87,7 +89,7 @@ func validateRequest(logger *zap.Logger, env common.Environment, perms *Permissi
if err != nil {
logger.Debug("failed to unmarshal request", zap.String("userName", permsForUser.userName), zap.Error(err))
invalidQueryRequestReceived.WithLabelValues("failed_to_unmarshal_request").Inc()
return http.StatusInternalServerError, fmt.Errorf("failed to unmarshal request: %w", err)
return http.StatusBadRequest, fmt.Errorf("failed to unmarshal request: %w", err)
}

// Make sure the overall query request is sane.
Expand All @@ -108,6 +110,8 @@ func validateRequest(logger *zap.Logger, env common.Environment, perms *Permissi
status, err = validateCallData(logger, permsForUser, "ethCallByTimestamp", pcq.ChainId, q.CallData)
case *query.EthCallWithFinalityQueryRequest:
status, err = validateCallData(logger, permsForUser, "ethCallWithFinality", pcq.ChainId, q.CallData)
case *query.SolanaAccountQueryRequest:
status, err = validateSolanaAccountQuery(logger, permsForUser, "solAccount", pcq.ChainId, q)
default:
logger.Debug("unsupported query type", zap.String("userName", permsForUser.userName), zap.Any("type", pcq.Query))
invalidQueryRequestReceived.WithLabelValues("unsupported_query_type").Inc()
Expand Down Expand Up @@ -151,3 +155,19 @@ func validateCallData(logger *zap.Logger, permsForUser *permissionEntry, callTag

return http.StatusOK, nil
}

// validateSolanaAccountQuery performs verification on a Solana sol_account query.
func validateSolanaAccountQuery(logger *zap.Logger, permsForUser *permissionEntry, callTag string, chainId vaa.ChainID, q *query.SolanaAccountQueryRequest) (int, error) {
for _, acct := range q.Accounts {
callKey := fmt.Sprintf("%s:%d:%s", callTag, chainId, solana.PublicKey(acct).String())
if _, exists := permsForUser.allowedCalls[callKey]; !exists {
logger.Debug("requested call not authorized", zap.String("userName", permsForUser.userName), zap.String("callKey", callKey))
invalidQueryRequestReceived.WithLabelValues("call_not_authorized").Inc()
return http.StatusBadRequest, fmt.Errorf(`call "%s" not authorized`, callKey)
}

totalRequestedCallsByChain.WithLabelValues(chainId.String()).Inc()
}

return http.StatusOK, nil
}
141 changes: 141 additions & 0 deletions node/hack/query/send_req.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
"github.com/tendermint/tendermint/libs/rand"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"

"github.com/gagliardetto/solana-go"
)

// this script has to be run inside kubernetes since it relies on UDP
Expand Down Expand Up @@ -117,6 +119,40 @@ func main() {
// END SETUP
//

//
// Solana Tests
//

{
logger.Info("Running Solana tests")

// Start of query creation...
account1, err := solana.PublicKeyFromBase58("Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o")
if err != nil {
panic("solana account1 is invalid")
}
account2, err := solana.PublicKeyFromBase58("B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE")
if err != nil {
panic("solana account2 is invalid")
}
callRequest := &query.SolanaAccountQueryRequest{
Commitment: "finalized",
DataSliceOffset: 0,
DataSliceLength: 100,
Accounts: [][query.SolanaPublicKeyLength]byte{account1, account2},
}

queryRequest := createSolanaQueryRequest(callRequest)
sendSolanaQueryAndGetRsp(queryRequest, sk, th_req, ctx, logger, sub)

logger.Info("Solana tests complete!")
}
// return

//
// EVM Tests
//

wethAbi, err := abi.JSON(strings.NewReader("[{\"constant\":true,\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"name\":\"\",\"type\":\"string\"}],\"payable\":false,\"stateMutability\":\"view\",\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"payable\":false,\"stateMutability\":\"view\",\"type\":\"function\"}]"))
if err != nil {
panic(err)
Expand Down Expand Up @@ -355,3 +391,108 @@ func sendQueryAndGetRsp(queryRequest *query.QueryRequest, sk *ecdsa.PrivateKey,
}
}
}

func createSolanaQueryRequest(callRequest *query.SolanaAccountQueryRequest) *query.QueryRequest {
queryRequest := &query.QueryRequest{
Nonce: rand.Uint32(),
PerChainQueries: []*query.PerChainQueryRequest{
{
ChainId: 1,
Query: callRequest,
},
},
}
return queryRequest
}

func sendSolanaQueryAndGetRsp(queryRequest *query.QueryRequest, sk *ecdsa.PrivateKey, th *pubsub.Topic, ctx context.Context, logger *zap.Logger, sub *pubsub.Subscription) {
queryRequestBytes, err := queryRequest.Marshal()
if err != nil {
panic(err)
}
numQueries := len(queryRequest.PerChainQueries)

// Sign the query request using our private key.
digest := query.QueryRequestDigest(common.UnsafeDevNet, queryRequestBytes)
sig, err := ethCrypto.Sign(digest.Bytes(), sk)
if err != nil {
panic(err)
}

signedQueryRequest := &gossipv1.SignedQueryRequest{
QueryRequest: queryRequestBytes,
Signature: sig,
}

msg := gossipv1.GossipMessage{
Message: &gossipv1.GossipMessage_SignedQueryRequest{
SignedQueryRequest: signedQueryRequest,
},
}

b, err := proto.Marshal(&msg)
if err != nil {
panic(err)
}

err = th.Publish(ctx, b)
if err != nil {
panic(err)
}

logger.Info("Waiting for message...")
// TODO: max wait time
// TODO: accumulate signatures to reach quorum
for {
envelope, err := sub.Next(ctx)
if err != nil {
logger.Panic("failed to receive pubsub message", zap.Error(err))
}
var msg gossipv1.GossipMessage
err = proto.Unmarshal(envelope.Data, &msg)
if err != nil {
logger.Info("received invalid message",
zap.Binary("data", envelope.Data),
zap.String("from", envelope.GetFrom().String()))
continue
}
var isMatchingResponse bool
switch m := msg.Message.(type) {
case *gossipv1.GossipMessage_SignedQueryResponse:
logger.Info("query response received", zap.Any("response", m.SignedQueryResponse),
zap.String("responseBytes", hexutil.Encode(m.SignedQueryResponse.QueryResponse)),
zap.String("sigBytes", hexutil.Encode(m.SignedQueryResponse.Signature)))
isMatchingResponse = true

var response query.QueryResponsePublication
err := response.Unmarshal(m.SignedQueryResponse.QueryResponse)
if err != nil {
logger.Warn("failed to unmarshal response", zap.Error(err))
break
}
if bytes.Equal(response.Request.QueryRequest, queryRequestBytes) && bytes.Equal(response.Request.Signature, sig) {
// TODO: verify response signature
isMatchingResponse = true

if len(response.PerChainResponses) != numQueries {
logger.Warn("unexpected number of per chain query responses", zap.Int("expectedNum", numQueries), zap.Int("actualNum", len(response.PerChainResponses)))
break
}
// Do double loop over responses
for index := range response.PerChainResponses {
switch r := response.PerChainResponses[index].Response.(type) {
case *query.SolanaAccountQueryResponse:
logger.Info("solana query per chain response", zap.Int("index", index), zap.Any("pcr", r))
default:
panic(fmt.Sprintf("unsupported query type, should be solana, index: %d", index))
}
}
}
default:
continue
}
if isMatchingResponse {
break
}
}
}
5 changes: 2 additions & 3 deletions node/pkg/node/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,11 +370,10 @@ func GuardianOptionWatchers(watcherConfigs []watchers.WatcherConfig, ibcWatcherC

if wc.GetNetworkID() != "solana-confirmed" { // TODO this should not be a special case, see comment in common/readiness.go
common.MustRegisterReadinessSyncing(wc.GetChainID())
chainObsvReqC[wc.GetChainID()] = make(chan *gossipv1.ObservationRequest, observationRequestPerChainBufferSize)
g.chainQueryReqC[wc.GetChainID()] = make(chan *query.PerChainQueryInternal, query.QueryRequestBufferSize)
}

chainObsvReqC[wc.GetChainID()] = make(chan *gossipv1.ObservationRequest, observationRequestPerChainBufferSize)
g.chainQueryReqC[wc.GetChainID()] = make(chan *query.PerChainQueryInternal, query.QueryRequestBufferSize)

if wc.RequiredL1Finalizer() != "" {
l1watcher, ok := watchers[wc.RequiredL1Finalizer()]
if !ok || l1watcher == nil {
Expand Down
3 changes: 2 additions & 1 deletion node/pkg/query/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ func handleQueryRequestsImpl(

pendingQueries := make(map[string]*pendingQuery) // Key is requestID.

// CCQ is currently only supported on EVM.
// CCQ is currently only supported on EVM and Solana.
supportedChains := map[vaa.ChainID]struct{}{
vaa.ChainIDSolana: {},
vaa.ChainIDEthereum: {},
vaa.ChainIDBSC: {},
vaa.ChainIDPolygon: {},
Expand Down
Loading

0 comments on commit 59dff67

Please sign in to comment.