From b265e16ed695ae1c23715b70891f61e112931119 Mon Sep 17 00:00:00 2001 From: Bruce Riley Date: Tue, 6 Aug 2024 13:27:04 -0500 Subject: [PATCH 1/3] Node/CCQ: Allow address wildcard --- node/cmd/ccq/devnet.permissions.json | 2 +- node/cmd/ccq/parse_config_test.go | 157 +++++++++++++++++++ node/cmd/ccq/permissions.go | 10 +- node/cmd/ccq/utils.go | 10 +- node/pkg/watchers/evm/blocks_by_timestamp.go | 14 ++ node/pkg/watchers/evm/ccq.go | 24 ++- 6 files changed, 209 insertions(+), 8 deletions(-) diff --git a/node/cmd/ccq/devnet.permissions.json b/node/cmd/ccq/devnet.permissions.json index 6f9578df65..cac3a2952a 100644 --- a/node/cmd/ccq/devnet.permissions.json +++ b/node/cmd/ccq/devnet.permissions.json @@ -8,7 +8,7 @@ "ethCall": { "note:": "Name of WETH on Goerli", "chain": 2, - "contractAddress": "B4FBF271143F4FBf7B91A5ded31805e42b2208d6", + "contractAddress": "*", "call": "0x06fdde03" } }, diff --git a/node/cmd/ccq/parse_config_test.go b/node/cmd/ccq/parse_config_test.go index eaa1f59262..b066ebaf9e 100644 --- a/node/cmd/ccq/parse_config_test.go +++ b/node/cmd/ccq/parse_config_test.go @@ -1,10 +1,15 @@ package ccq import ( + "encoding/hex" + "strings" "testing" + "github.com/certusone/wormhole/node/pkg/query" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/wormhole-foundation/wormhole/sdk/vaa" + "go.uber.org/zap" ) func TestParseConfigFileDoesntExist(t *testing.T) { @@ -461,3 +466,155 @@ func TestParseConfigAllowAnythingSuccess(t *testing.T) { require.True(t, ok) assert.True(t, perm.allowAnything) } + +func TestParseConfigContractWildcard(t *testing.T) { + str := ` + { + "permissions": [ + { + "userName": "Test User", + "apiKey": "my_secret_key", + "allowedCalls": [ + { + "ethCall": { + "note:": "Name of anything on Goerli", + "chain": 2, + "contractAddress": "*", + "call": "0x06fdde03" + } + }, + { + "ethCallByTimestamp": { + "note:": "Total supply of WETH on Goerli", + "chain": 2, + "contractAddress": "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", + "call": "0x18160ddd" + } + } + ] + } + ] +}` + + perms, err := parseConfig([]byte(str), true) + require.NoError(t, err) + assert.Equal(t, 1, len(perms)) + + permsForUser, ok := perms["my_secret_key"] + require.True(t, ok) + assert.Equal(t, 2, len(permsForUser.allowedCalls)) + + logger := zap.NewNop() + + type testCase struct { + label string + callType string + chainID vaa.ChainID + contractAddress string + data string + errText string // empty string means success + } + + var testCases = []testCase{ + { + label: "Wild card, success", + callType: "ethCall", + chainID: vaa.ChainIDEthereum, + contractAddress: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", + data: "0x06fdde03", + errText: "", + }, + { + label: "Wild card, success, different address", + callType: "ethCall", + chainID: vaa.ChainIDEthereum, + contractAddress: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d7", + data: "0x06fdde03", + errText: "", + }, + { + label: "Wild card, wrong call type", + callType: "ethCallByTimestamp", + chainID: vaa.ChainIDEthereum, + contractAddress: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", + data: "0x06fdde03", + errText: "not authorized", + }, + { + label: "Wild card, wrong chain", + callType: "ethCall", + chainID: vaa.ChainIDBase, + contractAddress: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", + data: "0x06fdde03", + errText: "not authorized", + }, + { + label: "Specific, success", + callType: "ethCallByTimestamp", + chainID: vaa.ChainIDEthereum, + contractAddress: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", + data: "0x18160ddd", + errText: "", + }, + { + label: "Specific, wrong call type", + callType: "ethCall", + chainID: vaa.ChainIDEthereum, + contractAddress: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", + data: "0x18160ddd", + errText: "not authorized", + }, + { + label: "Specific, wrong chain", + callType: "ethCallByTimestamp", + chainID: vaa.ChainIDBase, + contractAddress: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", + data: "0x18160ddd", + errText: "not authorized", + }, + { + label: "Specific, wrong address", + callType: "ethCallByTimestamp", + chainID: vaa.ChainIDEthereum, + contractAddress: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d7", + data: "0x18160ddd", + errText: "not authorized", + }, + { + label: "Specific, wrong data", + callType: "ethCallByTimestamp", + chainID: vaa.ChainIDEthereum, + contractAddress: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", + data: "0x18160dde", + errText: "not authorized", + }, + } + + for _, tst := range testCases { + t.Run(tst.label, func(t *testing.T) { + status, err := validateCallData(logger, permsForUser, tst.callType, tst.chainID, createCallData(t, tst.contractAddress, tst.data)) + if tst.errText == "" { + require.NoError(t, err) + assert.Equal(t, 200, status) + } else { + require.ErrorContains(t, err, tst.errText) + } + }) + } +} + +func createCallData(t *testing.T, toStr string, dataStr string) []*query.EthCallData { + t.Helper() + to, err := vaa.StringToAddress(strings.TrimPrefix(toStr, "0x")) + require.NoError(t, err) + + data, err := hex.DecodeString(strings.TrimPrefix(dataStr, "0x")) + require.NoError(t, err) + + return []*query.EthCallData{ + { + To: to.Bytes(), + Data: data, + }, + } +} diff --git a/node/cmd/ccq/permissions.go b/node/cmd/ccq/permissions.go index 826afbc519..86c2916910 100644 --- a/node/cmd/ccq/permissions.go +++ b/node/cmd/ccq/permissions.go @@ -280,9 +280,13 @@ func parseConfig(byteValue []byte, allowAnything bool) (PermissionsMap, error) { 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) + contractAddress := contractAddressStr + if contractAddressStr != "*" { + contractAddr, err := vaa.StringToAddress(contractAddressStr) + if err != nil { + return nil, fmt.Errorf(`invalid contract address "%s" for user "%s"`, contractAddressStr, user.UserName) + } + contractAddress = contractAddr.String() } // The call should be the ABI four byte hex hash of the function signature. Parse it into a standard form of "06fdde03". diff --git a/node/cmd/ccq/utils.go b/node/cmd/ccq/utils.go index a4523af24a..046f8a34da 100644 --- a/node/cmd/ccq/utils.go +++ b/node/cmd/ccq/utils.go @@ -149,9 +149,13 @@ func validateCallData(logger *zap.Logger, permsForUser *permissionEntry, callTag call := hex.EncodeToString(cd.Data[0:ETH_CALL_SIG_LENGTH]) callKey := fmt.Sprintf("%s:%d:%s:%s", callTag, chainId, contractAddress, call) 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) + // The call data doesn't exist including the contract address. See if it's covered by a wildcard. + wildCardCallKey := fmt.Sprintf("%s:%d:*:%s", callTag, chainId, call) + if _, exists := permsForUser.allowedCalls[wildCardCallKey]; !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) + } } } diff --git a/node/pkg/watchers/evm/blocks_by_timestamp.go b/node/pkg/watchers/evm/blocks_by_timestamp.go index afbcd09934..b48090fbc2 100644 --- a/node/pkg/watchers/evm/blocks_by_timestamp.go +++ b/node/pkg/watchers/evm/blocks_by_timestamp.go @@ -166,3 +166,17 @@ func (lhs Block) Cmp(rhs Block) int { return 0 } + +// GetRange returns the range covered by the cache for debugging purposes. +func (bts *BlocksByTimestamp) GetRange() (firstBlockNum, firstBlockTime, lastBlockNum, lastBlockTime uint64) { + bts.mutex.Lock() + defer bts.mutex.Unlock() + + l := len(bts.cache) + if l <= 0 { + return 0, 0, 0, 0 + } + + l = l - 1 + return bts.cache[0].BlockNum, bts.cache[0].Timestamp, bts.cache[l].BlockNum, bts.cache[l].Timestamp +} diff --git a/node/pkg/watchers/evm/ccq.go b/node/pkg/watchers/evm/ccq.go index ab1999c4cd..7d1efc4c8f 100644 --- a/node/pkg/watchers/evm/ccq.go +++ b/node/pkg/watchers/evm/ccq.go @@ -220,9 +220,11 @@ func (w *Watcher) ccqHandleEthCallByTimestampQueryRequest(ctx context.Context, q } // Look the timestamp up in the cache. Note that the cache uses native EVM time, which is seconds, but CCQ uses microseconds, so we have to convert. - blockNum, nextBlockNum, found := w.ccqTimestampCache.LookUp(req.TargetTimestamp / 1000000) + timestampForCache := req.TargetTimestamp / 1000000 + blockNum, nextBlockNum, found := w.ccqTimestampCache.LookUp(timestampForCache) if !found { status := query.QueryRetryNeeded + firstBlockNum, firstBlockTime, lastBlockNum, lastBlockTime := w.ccqTimestampCache.GetRange() if nextBlockNum == 0 { w.ccqLogger.Warn("block look up failed in eth_call_by_timestamp query request, timestamp beyond the end of the cache, will wait and retry", zap.String("requestId", requestId), @@ -231,6 +233,11 @@ func (w *Watcher) ccqHandleEthCallByTimestampQueryRequest(ctx context.Context, q zap.String("nextBlock", nextBlock), zap.Uint64("blockNum", blockNum), zap.Uint64("nextBlockNum", nextBlockNum), + zap.Uint64("timestampForCache", timestampForCache), + zap.Uint64("firstBlockNum", firstBlockNum), + zap.Uint64("firstBlockTime", firstBlockTime), + zap.Uint64("lastBlockNum", lastBlockNum), + zap.Uint64("lastBlockTime", lastBlockTime), ) } else if blockNum == 0 { w.ccqLogger.Error("block look up failed in eth_call_by_timestamp query request, timestamp too old, failing request", @@ -240,6 +247,11 @@ func (w *Watcher) ccqHandleEthCallByTimestampQueryRequest(ctx context.Context, q zap.String("nextBlock", nextBlock), zap.Uint64("blockNum", blockNum), zap.Uint64("nextBlockNum", nextBlockNum), + zap.Uint64("timestampForCache", timestampForCache), + zap.Uint64("firstBlockNum", firstBlockNum), + zap.Uint64("firstBlockTime", firstBlockTime), + zap.Uint64("lastBlockNum", lastBlockNum), + zap.Uint64("lastBlockTime", lastBlockTime), ) status = query.QueryFatalError } else if w.ccqBackfillCache { @@ -250,6 +262,11 @@ func (w *Watcher) ccqHandleEthCallByTimestampQueryRequest(ctx context.Context, q zap.String("nextBlock", nextBlock), zap.Uint64("blockNum", blockNum), zap.Uint64("nextBlockNum", nextBlockNum), + zap.Uint64("timestampForCache", timestampForCache), + zap.Uint64("firstBlockNum", firstBlockNum), + zap.Uint64("firstBlockTime", firstBlockTime), + zap.Uint64("lastBlockNum", lastBlockNum), + zap.Uint64("lastBlockTime", lastBlockTime), ) w.ccqRequestBackfill(req.TargetTimestamp / 1000000) } else { @@ -260,6 +277,11 @@ func (w *Watcher) ccqHandleEthCallByTimestampQueryRequest(ctx context.Context, q zap.String("nextBlock", nextBlock), zap.Uint64("blockNum", blockNum), zap.Uint64("nextBlockNum", nextBlockNum), + zap.Uint64("timestampForCache", timestampForCache), + zap.Uint64("firstBlockNum", firstBlockNum), + zap.Uint64("firstBlockTime", firstBlockTime), + zap.Uint64("lastBlockNum", lastBlockNum), + zap.Uint64("lastBlockTime", lastBlockTime), ) status = query.QueryFatalError } From b6c6eceed114aec63cd78dcca6bb6b84bc6d97ea Mon Sep 17 00:00:00 2001 From: Bruce Riley Date: Tue, 6 Aug 2024 14:20:04 -0500 Subject: [PATCH 2/3] Code review rework --- node/pkg/watchers/evm/ccq.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/pkg/watchers/evm/ccq.go b/node/pkg/watchers/evm/ccq.go index 7d1efc4c8f..8faa5a4516 100644 --- a/node/pkg/watchers/evm/ccq.go +++ b/node/pkg/watchers/evm/ccq.go @@ -268,7 +268,7 @@ func (w *Watcher) ccqHandleEthCallByTimestampQueryRequest(ctx context.Context, q zap.Uint64("lastBlockNum", lastBlockNum), zap.Uint64("lastBlockTime", lastBlockTime), ) - w.ccqRequestBackfill(req.TargetTimestamp / 1000000) + w.ccqRequestBackfill(timestampForCache) } else { w.ccqLogger.Error("block look up failed in eth_call_by_timestamp query request, timestamp is in a gap in the cache, failing request", zap.String("requestId", requestId), From 2f2877372ffa8e2f54b89cdae657dcd8dd13a267 Mon Sep 17 00:00:00 2001 From: Bruce Riley Date: Tue, 6 Aug 2024 14:40:19 -0500 Subject: [PATCH 3/3] Update the docs --- docs/query_proxy.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/query_proxy.md b/docs/query_proxy.md index cce097ea66..c413721c74 100644 --- a/docs/query_proxy.md +++ b/docs/query_proxy.md @@ -143,6 +143,11 @@ The following are the Solana call types. Both require the `chain` parameter plus The Solana account and and program address can be expressed as either a 32 byte hex string starting with "0x" or as a base 58 value. +#### Wild Card Contract Addresses + +For the eth calls, the `contractAddress` field may be set to `"*"` which means the specified call type and call may be made to any +contract address on the specified chain. + #### Creating New API Keys Each user must have an API key. These keys only have meaning to the proxy server. They are not passed to the guardians.