Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Node/CCQ: Solana query support #3637

Merged
merged 10 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading