diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33144b990..edddcb9b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ on: push: branches: - main + - morpho-main permissions: attestations: write @@ -121,14 +122,14 @@ jobs: - Generated release files - Generated checksums branch: "release/${{ github.event.inputs.version_tag }}" - base: main + base: morpho-main labels: release # Run on release PR merge release: if: | github.event_name == 'push' && - github.ref == 'refs/heads/main' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/morpho-main') && contains(github.event.head_commit.message, 'chore: release') runs-on: "${{ github.repository_owner == 'erpc' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}" steps: @@ -250,7 +251,7 @@ jobs: docker-build-amd64: runs-on: "${{ github.repository_owner == 'erpc' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}" needs: [prepare-release, release] - if: always() && github.ref == 'refs/heads/main' + if: always() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/morpho-main') timeout-minutes: 35 outputs: digest_main: ${{ steps.build_main.outputs.digest }} @@ -294,7 +295,7 @@ jobs: VERSION=$(echo "${{ github.event.head_commit.message }}" | grep -oP 'release \K([0-9]+\.[0-9]+\.[0-9]+)') echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - - name: Build+push by digest (main) + - name: Build+push by digest (branch) id: build_main if: github.event.inputs.version_tag == '' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 @@ -302,7 +303,7 @@ jobs: context: . platforms: linux/amd64 build-args: | - VERSION=main + VERSION=${{ github.ref_name }} COMMIT_SHA=${{ steps.meta.outputs.short_sha }} sbom: false # not supported on docker driver, maybe works with blacksmith? outputs: type=registry,name=ghcr.io/${{ steps.meta.outputs.repo }},push-by-digest=true @@ -331,7 +332,7 @@ jobs: docker-build-arm64: runs-on: "${{ github.repository_owner == 'erpc' && 'blacksmith-8vcpu-ubuntu-2404-arm' || 'ubuntu-24.04-arm' }}" needs: [prepare-release, release] - if: always() && github.ref == 'refs/heads/main' + if: always() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/morpho-main') timeout-minutes: 35 outputs: digest_main: ${{ steps.build_main.outputs.digest }} @@ -375,7 +376,7 @@ jobs: VERSION=$(echo "${{ github.event.head_commit.message }}" | grep -oP 'release \K([0-9]+\.[0-9]+\.[0-9]+)') echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - - name: Build+push by digest (main) + - name: Build+push by digest (branch) id: build_main if: github.event.inputs.version_tag == '' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 @@ -383,7 +384,7 @@ jobs: context: . platforms: linux/arm64 build-args: | - VERSION=main + VERSION=${{ github.ref_name }} COMMIT_SHA=${{ steps.meta.outputs.short_sha }} sbom: false # not supported on docker driver, maybe works with blacksmith? outputs: type=registry,name=ghcr.io/${{ steps.meta.outputs.repo }},push-by-digest=true @@ -412,7 +413,7 @@ jobs: docker-manifest: runs-on: "${{ github.repository_owner == 'erpc' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}" needs: [docker-build-amd64, docker-build-arm64] - if: always() && github.ref == 'refs/heads/main' + if: always() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/morpho-main') steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 @@ -439,17 +440,17 @@ jobs: VERSION=$(echo "${{ github.event.head_commit.message }}" | grep -oP 'release \K([0-9]+\.[0-9]+\.[0-9]+)') echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - - name: Create multi-arch manifest for main + - name: Create multi-arch manifest for branch id: main if: github.event.inputs.version_tag == '' run: | docker buildx imagetools create \ - -t ghcr.io/${{ steps.meta.outputs.repo }}:main \ + -t ghcr.io/${{ steps.meta.outputs.repo }}:${{ github.ref_name }} \ ghcr.io/${{ steps.meta.outputs.repo }}@${{ needs.docker-build-amd64.outputs.digest_main }} \ ghcr.io/${{ steps.meta.outputs.repo }}@${{ needs.docker-build-arm64.outputs.digest_main }} - docker pull ghcr.io/${{ steps.meta.outputs.repo }}:main - DIGEST_MAIN=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/${{ steps.meta.outputs.repo }}:main) + docker pull ghcr.io/${{ steps.meta.outputs.repo }}:${{ github.ref_name }} + DIGEST_MAIN=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/${{ steps.meta.outputs.repo }}:${{ github.ref_name }}) echo "digest_main=${DIGEST_MAIN#*@}" >> "$GITHUB_OUTPUT" - name: Create multi-arch manifests for release @@ -502,7 +503,7 @@ jobs: docker-cleanup-sha256-tags: runs-on: "${{ github.repository_owner == 'erpc' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}" needs: [docker-manifest] - if: always() && github.ref == 'refs/heads/main' + if: always() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/morpho-main') steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 diff --git a/erpc/networks_failsafe_test.go b/erpc/networks_failsafe_test.go index 33ecff2d6..9964623ff 100644 --- a/erpc/networks_failsafe_test.go +++ b/erpc/networks_failsafe_test.go @@ -1398,7 +1398,7 @@ func TestNetworkFailsafe_UpstreamGroupFilter(t *testing.T) { }, } - rateLimitersRegistry, err := upstream.NewRateLimitersRegistry(&common.RateLimiterConfig{}, &log.Logger) + rateLimitersRegistry, err := upstream.NewRateLimitersRegistry(context.Background(), &common.RateLimiterConfig{}, &log.Logger) require.NoError(t, err) metricsTracker := health.NewTracker(&log.Logger, "test", time.Minute) @@ -1509,7 +1509,7 @@ func TestNetworkFailsafe_UpstreamGroupFilter(t *testing.T) { }, } - rateLimitersRegistry, err := upstream.NewRateLimitersRegistry(&common.RateLimiterConfig{}, &log.Logger) + rateLimitersRegistry, err := upstream.NewRateLimitersRegistry(context.Background(), &common.RateLimiterConfig{}, &log.Logger) require.NoError(t, err) metricsTracker := health.NewTracker(&log.Logger, "test", time.Minute) diff --git a/test/integration/sqd_portal_wrapper_integration_test.go b/test/integration/sqd_portal_wrapper_integration_test.go new file mode 100644 index 000000000..11cce19a0 --- /dev/null +++ b/test/integration/sqd_portal_wrapper_integration_test.go @@ -0,0 +1,317 @@ +// Package integration contains integration tests that run against live services. +// +// SQD wrapper tests are skipped unless SQD_WRAPPER_E2E_ENDPOINT is set. +// +// Environment variables: +// - SQD_WRAPPER_E2E_ENDPOINT: wrapper JSON-RPC endpoint (required) +// - SQD_WRAPPER_E2E_CHAIN_ID: chain id for X-Chain-Id header (optional, default 1) +// - SQD_WRAPPER_E2E_AUTH: auth headers "Header: value" format, ";" separated (optional) +// +// Usage: +// +// SQD_WRAPPER_E2E_ENDPOINT=http://localhost:8080 \ +// SQD_WRAPPER_E2E_CHAIN_ID=1 \ +// go test -v -run TestSqdPortalWrapper_Methods ./test/integration/... +package integration + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type sqdJsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params"` +} + +type sqdJsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *json.RawMessage `json:"error,omitempty"` +} + +type sqdRpcResponse struct { + Status int + Body []byte + Parsed sqdJsonRPCResponse +} + +type sqdCapabilitiesResponse struct { + Methods []string `json:"methods"` +} + +func getWrapperEndpoint(t *testing.T) string { + endpoint := strings.TrimSpace(os.Getenv("SQD_WRAPPER_E2E_ENDPOINT")) + if endpoint == "" { + t.Skip("SQD_WRAPPER_E2E_ENDPOINT not set, skipping SQD wrapper integration test") + } + return endpoint +} + +func getWrapperChainID() string { + chainID := strings.TrimSpace(os.Getenv("SQD_WRAPPER_E2E_CHAIN_ID")) + if chainID == "" { + return "1" + } + return chainID +} + +func getWrapperAuthHeaders() map[string]string { + headers := make(map[string]string) + raw := os.Getenv("SQD_WRAPPER_E2E_AUTH") + if raw == "" { + return headers + } + parts := strings.Split(raw, ";") + for _, part := range parts { + part = strings.TrimSpace(part) + if idx := strings.Index(part, ":"); idx > 0 { + key := strings.TrimSpace(part[:idx]) + value := strings.TrimSpace(part[idx+1:]) + headers[key] = value + } + } + return headers +} + +func wrapperBaseURL(endpoint string) string { + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme == "" { + return "http://" + strings.TrimRight(endpoint, "/") + } + if parsed.Host == "" { + return strings.TrimRight(endpoint, "/") + } + return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) +} + +func fetchWrapperCapabilities(t *testing.T, endpoint string) map[string]bool { + t.Helper() + + base := wrapperBaseURL(endpoint) + req, err := http.NewRequest("GET", base+"/capabilities", nil) + require.NoError(t, err) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "capabilities status %d: %s", resp.StatusCode, string(data)) + + var parsed sqdCapabilitiesResponse + require.NoError(t, json.Unmarshal(data, &parsed)) + + methods := make(map[string]bool, len(parsed.Methods)) + for _, method := range parsed.Methods { + methods[method] = true + } + return methods +} + +func makeWrapperRequest(t *testing.T, endpoint string, payload interface{}) sqdRpcResponse { + t.Helper() + + body, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Chain-Id", getWrapperChainID()) + + for key, value := range getWrapperAuthHeaders() { + req.Header.Set(key, value) + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var parsed sqdJsonRPCResponse + require.NoError(t, json.Unmarshal(data, &parsed)) + return sqdRpcResponse{ + Status: resp.StatusCode, + Body: data, + Parsed: parsed, + } +} + +func parseHexInt64(t *testing.T, value string) int64 { + t.Helper() + require.True(t, strings.HasPrefix(value, "0x"), "expected hex value, got %q", value) + parsed, err := strconv.ParseInt(strings.TrimPrefix(value, "0x"), 16, 64) + require.NoError(t, err, fmt.Sprintf("invalid hex value: %q", value)) + return parsed +} + +func TestSqdPortalWrapper_Methods(t *testing.T) { + endpoint := getWrapperEndpoint(t) + capabilities := fetchWrapperCapabilities(t, endpoint) + + baseMethods := []string{ + "eth_chainId", + "eth_blockNumber", + "eth_getBlockByNumber", + "eth_getTransactionByBlockNumberAndIndex", + "eth_getLogs", + "trace_block", + } + upstreamOnlyMethods := []string{ + "eth_getBlockByHash", + "eth_getTransactionByHash", + "eth_getTransactionReceipt", + "trace_transaction", + } + + for _, method := range baseMethods { + if !capabilities[method] { + t.Fatalf("capabilities missing base method %s", method) + } + } + for _, method := range upstreamOnlyMethods { + if capabilities[method] { + t.Logf("capabilities advertises upstream-only method %s; skipping per test config", method) + } + } + + t.Run("eth_chainId", func(t *testing.T) { + resp := makeWrapperRequest(t, endpoint, sqdJsonRPCRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "eth_chainId", + Params: []interface{}{}, + }) + if resp.Status != http.StatusOK { + t.Fatalf("unexpected status %d: %s", resp.Status, string(resp.Body)) + } + if resp.Parsed.Error != nil { + t.Fatalf("rpc error: %s", string(*resp.Parsed.Error)) + } + var chainIDHex string + require.NoError(t, json.Unmarshal(resp.Parsed.Result, &chainIDHex)) + parseHexInt64(t, chainIDHex) + }) + + t.Run("eth_blockNumber", func(t *testing.T) { + resp := makeWrapperRequest(t, endpoint, sqdJsonRPCRequest{ + JSONRPC: "2.0", + ID: 2, + Method: "eth_blockNumber", + Params: []interface{}{}, + }) + if resp.Status != http.StatusOK { + t.Fatalf("unexpected status %d: %s", resp.Status, string(resp.Body)) + } + if resp.Parsed.Error != nil { + t.Fatalf("rpc error: %s", string(*resp.Parsed.Error)) + } + var blockNumberHex string + require.NoError(t, json.Unmarshal(resp.Parsed.Result, &blockNumberHex)) + parseHexInt64(t, blockNumberHex) + }) + + t.Run("eth_getBlockByNumber", func(t *testing.T) { + resp := makeWrapperRequest(t, endpoint, sqdJsonRPCRequest{ + JSONRPC: "2.0", + ID: 3, + Method: "eth_getBlockByNumber", + Params: []interface{}{"latest", false}, + }) + if resp.Status != http.StatusOK { + t.Fatalf("unexpected status %d: %s", resp.Status, string(resp.Body)) + } + if resp.Parsed.Error != nil { + t.Fatalf("rpc error: %s", string(*resp.Parsed.Error)) + } + if string(resp.Parsed.Result) == "null" { + t.Skip("no block returned for latest") + } + var block map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Parsed.Result, &block)) + }) + + t.Run("eth_getTransactionByBlockNumberAndIndex", func(t *testing.T) { + resp := makeWrapperRequest(t, endpoint, sqdJsonRPCRequest{ + JSONRPC: "2.0", + ID: 4, + Method: "eth_getTransactionByBlockNumberAndIndex", + Params: []interface{}{"latest", "0x0"}, + }) + if resp.Status != http.StatusOK { + t.Fatalf("unexpected status %d: %s", resp.Status, string(resp.Body)) + } + if resp.Parsed.Error != nil { + t.Fatalf("rpc error: %s", string(*resp.Parsed.Error)) + } + if string(resp.Parsed.Result) != "null" { + var tx map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Parsed.Result, &tx)) + } + }) + + t.Run("eth_getLogs", func(t *testing.T) { + resp := makeWrapperRequest(t, endpoint, sqdJsonRPCRequest{ + JSONRPC: "2.0", + ID: 5, + Method: "eth_getLogs", + Params: []interface{}{map[string]interface{}{}}, + }) + if resp.Status != http.StatusOK { + t.Fatalf("unexpected status %d: %s", resp.Status, string(resp.Body)) + } + if resp.Parsed.Error != nil { + t.Fatalf("rpc error: %s", string(*resp.Parsed.Error)) + } + var logs []interface{} + require.NoError(t, json.Unmarshal(resp.Parsed.Result, &logs)) + }) + + t.Run("trace_block", func(t *testing.T) { + resp := makeWrapperRequest(t, endpoint, sqdJsonRPCRequest{ + JSONRPC: "2.0", + ID: 6, + Method: "trace_block", + Params: []interface{}{"latest"}, + }) + if resp.Status != http.StatusOK { + t.Fatalf("unexpected status %d: %s", resp.Status, string(resp.Body)) + } + if resp.Parsed.Error != nil { + t.Fatalf("rpc error: %s", string(*resp.Parsed.Error)) + } + var traces []interface{} + require.NoError(t, json.Unmarshal(resp.Parsed.Result, &traces)) + }) + + t.Run("eth_getLogs blockHash (requires upstream)", func(t *testing.T) { + t.Skip("requires upstream for blockHash filter") + }) + + for _, method := range upstreamOnlyMethods { + method := method + t.Run(method+" (requires upstream)", func(t *testing.T) { + t.Skip("requires upstream") + }) + } +} diff --git a/thirdparty/sqd.go b/thirdparty/sqd.go new file mode 100644 index 000000000..19c56bc1d --- /dev/null +++ b/thirdparty/sqd.go @@ -0,0 +1,166 @@ +package thirdparty + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/erpc/erpc/common" + "github.com/rs/zerolog" +) + +type SqdVendor struct { + common.Vendor +} + +func CreateSqdVendor() common.Vendor { + return &SqdVendor{} +} + +func (v *SqdVendor) Name() string { + return "sqd" +} + +func (v *SqdVendor) OwnsUpstream(ups *common.UpstreamConfig) bool { + if ups.VendorName == v.Name() { + return true + } + return strings.Contains(ups.Endpoint, "sqd.dev") +} + +func (v *SqdVendor) SupportsNetwork(ctx context.Context, logger *zerolog.Logger, settings common.VendorSettings, networkId string) (bool, error) { + if !strings.HasPrefix(networkId, "evm:") { + return false, nil + } + + chainID, err := strconv.ParseInt(strings.TrimPrefix(networkId, "evm:"), 10, 64) + if err != nil { + return false, err + } + + if dataset, ok := sqdDatasetFromSettings(settings, chainID); ok && dataset != "" { + return true, nil + } + + if !sqdUseDefaultDatasets(settings) { + return false, nil + } + + _, exists := sqdChainToDataset[chainID] + return exists, nil +} + +func (v *SqdVendor) GenerateConfigs(ctx context.Context, logger *zerolog.Logger, upstream *common.UpstreamConfig, settings common.VendorSettings) ([]*common.UpstreamConfig, error) { + if upstream.JsonRpc == nil { + upstream.JsonRpc = &common.JsonRpcUpstreamConfig{} + } + + if upstream.Evm == nil { + return nil, fmt.Errorf("sqd vendor requires upstream.evm to be defined") + } + + if upstream.Evm.ChainId == 0 { + return nil, fmt.Errorf("sqd vendor requires upstream.evm.chainId to be defined") + } + + if upstream.Endpoint == "" { + return nil, fmt.Errorf("sqd vendor requires upstream.endpoint to be defined (portal wrapper)") + } + + upstream.Type = common.UpstreamTypeEvm + upstream.VendorName = v.Name() + + if dataset, ok := sqdDatasetForChain(settings, upstream.Evm.ChainId); ok { + if endpoint, updated := sqdApplyDatasetToEndpoint(upstream.Endpoint, dataset); updated { + upstream.Endpoint = endpoint + } else if logger != nil { + logger.Warn(). + Int64("chainId", upstream.Evm.ChainId). + Str("dataset", dataset). + Str("endpoint", upstream.Endpoint). + Msg("sqd dataset resolved but could not be applied to endpoint format") + } + } else if strings.Contains(upstream.Endpoint, "{dataset}") { + return nil, fmt.Errorf("sqd endpoint contains {dataset} placeholder but no dataset found for chainId %d", upstream.Evm.ChainId) + } + + if upstream.IgnoreMethods == nil { + upstream.IgnoreMethods = []string{"*"} + } + if upstream.AllowMethods == nil { + upstream.AllowMethods = []string{ + "eth_chainId", + "eth_blockNumber", + "eth_getBlockByNumber", + "eth_getTransactionByBlockNumberAndIndex", + "eth_getLogs", + "trace_block", + } + } + + if apiKey, ok := settings["wrapperApiKey"].(string); ok && apiKey != "" { + header := "X-API-Key" + if headerOverride, ok := settings["wrapperApiKeyHeader"].(string); ok && headerOverride != "" { + header = headerOverride + } + if upstream.JsonRpc.Headers == nil { + upstream.JsonRpc.Headers = make(map[string]string) + } + if _, exists := upstream.JsonRpc.Headers[header]; !exists { + upstream.JsonRpc.Headers[header] = apiKey + } + } + + if logger != nil { + logger.Debug().Int64("chainId", upstream.Evm.ChainId).Interface("upstream", upstream).Msg("generated upstream from sqd provider") + } + + return []*common.UpstreamConfig{upstream}, nil +} + +func (v *SqdVendor) GetVendorSpecificErrorIfAny(req *common.NormalizedRequest, resp *http.Response, jrr interface{}, details map[string]interface{}) error { + if resp == nil { + return nil + } + + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return common.NewErrEndpointUnauthorized(fmt.Errorf("sqd portal wrapper unauthorized: %d", resp.StatusCode)) + case http.StatusTooManyRequests: + return common.NewErrEndpointCapacityExceeded(fmt.Errorf("sqd portal wrapper rate limited: %d", resp.StatusCode)) + case http.StatusPaymentRequired: + return common.NewErrEndpointBillingIssue(fmt.Errorf("sqd portal wrapper billing issue: %d", resp.StatusCode)) + } + + // Wrapper returns standard JSON-RPC errors; let generic normalization handle them. + return nil +} + +func sqdDatasetForChain(settings common.VendorSettings, chainId int64) (string, bool) { + if dataset, ok := sqdDatasetFromSettings(settings, chainId); ok { + return dataset, true + } + if !sqdUseDefaultDatasets(settings) { + return "", false + } + if dataset, ok := sqdChainToDataset[chainId]; ok { + return dataset, true + } + return "", false +} + +func sqdApplyDatasetToEndpoint(endpoint string, dataset string) (string, bool) { + if dataset == "" { + return endpoint, false + } + if strings.Contains(endpoint, "{dataset}") { + return strings.ReplaceAll(endpoint, "{dataset}", dataset), true + } + trimmed := strings.TrimRight(endpoint, "/") + if strings.HasSuffix(strings.ToLower(trimmed), "/datasets") { + return trimmed + "/" + dataset, true + } + return endpoint, false +} diff --git a/thirdparty/sqd_chain_mapping.go b/thirdparty/sqd_chain_mapping.go new file mode 100644 index 000000000..a9b9ab256 --- /dev/null +++ b/thirdparty/sqd_chain_mapping.go @@ -0,0 +1,216 @@ +package thirdparty + +import ( + "fmt" + "strconv" + "strings" + + "github.com/erpc/erpc/common" + "github.com/rs/zerolog/log" +) + +// sqdChainToDataset maps EVM chainId to SQD Portal dataset name. +// Dataset slug is the last segment of the Portal gateway URL. +// Kept for supported-chain detection and vendor settings overrides. +var sqdChainToDataset = map[int64]string{ + // Mainnets + 1: "ethereum-mainnet", + 10: "optimism-mainnet", + 56: "binance-mainnet", + 100: "gnosis-mainnet", + 137: "polygon-mainnet", + 250: "fantom-mainnet", + 324: "zksync-mainnet", + 8453: "base-mainnet", + 42161: "arbitrum-one", + 42170: "arbitrum-nova", + 43114: "avalanche-mainnet", + 59144: "linea-mainnet", + 534352: "scroll-mainnet", + 81457: "blast-mainnet", + 7777777: "zora-mainnet", + + // Testnets (commonly used) + 11155111: "ethereum-sepolia", + 84532: "base-sepolia", + 421614: "arbitrum-sepolia", + 11155420: "optimism-sepolia", +} + +// sqdSupportedChainIds returns a slice of all supported chain IDs. +func sqdSupportedChainIds() []int64 { + chains := make([]int64, 0, len(sqdChainToDataset)) + for chainId := range sqdChainToDataset { + chains = append(chains, chainId) + } + return chains +} + +func sqdDatasetFromSettings(settings common.VendorSettings, chainId int64) (string, bool) { + if settings == nil { + return "", false + } + + if dataset, ok := settings["dataset"].(string); ok && dataset != "" { + return dataset, true + } + + raw, ok := settings["datasetByChainId"] + if !ok { + return "", false + } + + return sqdDatasetFromMap(raw, chainId) +} + +// sqdDatasetFromMap normalizes any map type from YAML/JSON deserialization +// and looks up the dataset for the given chainId. +func sqdDatasetFromMap(raw interface{}, chainId int64) (string, bool) { + chainIdStr := strconv.FormatInt(chainId, 10) + + switch m := raw.(type) { + case map[string]interface{}: + return sqdExtractStringValue(m, chainIdStr, chainId) + case map[string]string: + if ds, ok := m[chainIdStr]; ok && ds != "" { + return ds, true + } + return "", false + case map[interface{}]interface{}: + return sqdDatasetFromInterfaceMap(m, chainId, chainIdStr) + case map[int]interface{}: + return sqdExtractStringValue(m, int(chainId), chainId) + case map[int]string: + if ds, ok := m[int(chainId)]; ok && ds != "" { + return ds, true + } + return "", false + case map[int64]interface{}: + return sqdExtractStringValue(m, chainId, chainId) + case map[int64]string: + if ds, ok := m[chainId]; ok && ds != "" { + return ds, true + } + return "", false + case map[float64]interface{}: + return sqdExtractStringValue(m, float64(chainId), chainId) + case map[float64]string: + if ds, ok := m[float64(chainId)]; ok && ds != "" { + return ds, true + } + return "", false + default: + log.Warn(). + Int64("chainId", chainId). + Str("type", fmt.Sprintf("%T", raw)). + Msg("sqd datasetByChainId unsupported type") + } + + return "", false +} + +// sqdExtractStringValue looks up a key in a map[K]interface{} and asserts the value is a string. +// Logs a warning if the key exists but the value is not a string. +func sqdExtractStringValue[K comparable](m map[K]interface{}, key K, chainId int64) (string, bool) { + raw, exists := m[key] + if !exists { + return "", false + } + ds, ok := raw.(string) + if !ok || ds == "" { + if !ok { + log.Warn(). + Int64("chainId", chainId). + Str("valueType", fmt.Sprintf("%T", raw)). + Msg("sqd datasetByChainId value is not a string, ignoring") + } + return "", false + } + return ds, true +} + +func sqdDatasetFromInterfaceMap(m map[interface{}]interface{}, chainId int64, chainIdStr string) (string, bool) { + for key, value := range m { + if !sqdChainIdKeyMatches(key, chainId, chainIdStr) { + continue + } + dataset, ok := value.(string) + if !ok || dataset == "" { + if !ok { + log.Warn(). + Int64("chainId", chainId). + Str("valueType", fmt.Sprintf("%T", value)). + Msg("sqd datasetByChainId value is not a string, ignoring") + } + return "", false + } + return dataset, true + } + return "", false +} + +func sqdUseDefaultDatasets(settings common.VendorSettings) bool { + if settings == nil { + return true + } + + raw, ok := settings["useDefaultDatasets"] + if !ok { + return true + } + + switch val := raw.(type) { + case bool: + return val + case string: + if parsed, ok := sqdParseBoolString(val); ok { + return parsed + } + log.Warn().Str("value", val).Msg("sqd useDefaultDatasets unrecognized string, defaulting to false") + return false + case int: + return val != 0 + case int64: + return val != 0 + case float64: + return val != 0 + default: + log.Warn().Str("type", fmt.Sprintf("%T", raw)).Msg("sqd useDefaultDatasets unsupported type, defaulting to false") + return false + } +} + +func sqdParseBoolString(raw string) (bool, bool) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + switch normalized { + case "true", "t", "1", "yes", "y", "on": + return true, true + case "false", "f", "0", "no", "n", "off": + return false, true + default: + return false, false + } +} + +func sqdChainIdKeyMatches(key interface{}, chainId int64, chainIdStr string) bool { + switch k := key.(type) { + case int: + return int64(k) == chainId + case int32: + return int64(k) == chainId + case int64: + return k == chainId + case uint: + return strconv.FormatUint(uint64(k), 10) == chainIdStr + case uint32: + return strconv.FormatUint(uint64(k), 10) == chainIdStr + case uint64: + return strconv.FormatUint(k, 10) == chainIdStr + case float64: + return k == float64(chainId) + case string: + return strings.TrimSpace(k) == chainIdStr + default: + return false + } +} diff --git a/thirdparty/sqd_chain_mapping_test.go b/thirdparty/sqd_chain_mapping_test.go new file mode 100644 index 000000000..fa4faa2ce --- /dev/null +++ b/thirdparty/sqd_chain_mapping_test.go @@ -0,0 +1,168 @@ +package thirdparty + +import ( + "testing" + + "github.com/erpc/erpc/common" +) + +func TestSqdChainToDataset(t *testing.T) { + tests := []struct { + name string + chainId int64 + expected string + }{ + {"ethereum mainnet", 1, "ethereum-mainnet"}, + {"polygon mainnet", 137, "polygon-mainnet"}, + {"arbitrum one", 42161, "arbitrum-one"}, + {"base mainnet", 8453, "base-mainnet"}, + {"optimism mainnet", 10, "optimism-mainnet"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dataset, ok := sqdChainToDataset[tt.chainId] + if !ok { + t.Fatalf("chain %d not found in sqdChainToDataset", tt.chainId) + } + if dataset != tt.expected { + t.Errorf("sqdChainToDataset[%d] = %q, want %q", tt.chainId, dataset, tt.expected) + } + }) + } +} + +func TestSqdSupportedChainIds(t *testing.T) { + chainIds := sqdSupportedChainIds() + + if len(chainIds) != len(sqdChainToDataset) { + t.Errorf("sqdSupportedChainIds() returned %d chains, want %d", len(chainIds), len(sqdChainToDataset)) + } + + chainIdSet := make(map[int64]bool) + for _, id := range chainIds { + chainIdSet[id] = true + } + for chainId := range sqdChainToDataset { + if !chainIdSet[chainId] { + t.Errorf("chainId %d from sqdChainToDataset is missing from sqdSupportedChainIds()", chainId) + } + } +} + +func TestSqdChainToDataset_UnknownChain(t *testing.T) { + unknownChainIds := []int64{999999, 0, -1, 123456789} + + for _, chainId := range unknownChainIds { + if dataset, ok := sqdChainToDataset[chainId]; ok { + t.Errorf("unexpected mapping for unknown chain %d: %q", chainId, dataset) + } + } +} + +func TestSqdDatasetFromSettings_MapInterfaceInterface(t *testing.T) { + settings := common.VendorSettings{ + "datasetByChainId": map[interface{}]interface{}{ + int(1): "ethereum-mainnet", + int64(10): "optimism-mainnet", + float64(137): "polygon-mainnet", + "42161": "arbitrum-one", + "not-a-chain": "ignore-me", + }, + } + + dataset, ok := sqdDatasetFromSettings(settings, 1) + if !ok { + t.Fatalf("expected dataset for chain 1") + } + if dataset != "ethereum-mainnet" { + t.Errorf("dataset = %q, want ethereum-mainnet", dataset) + } + + dataset, ok = sqdDatasetFromSettings(settings, 10) + if !ok { + t.Fatalf("expected dataset for chain 10") + } + if dataset != "optimism-mainnet" { + t.Errorf("dataset = %q, want optimism-mainnet", dataset) + } + + dataset, ok = sqdDatasetFromSettings(settings, 137) + if !ok { + t.Fatalf("expected dataset for chain 137") + } + if dataset != "polygon-mainnet" { + t.Errorf("dataset = %q, want polygon-mainnet", dataset) + } + + dataset, ok = sqdDatasetFromSettings(settings, 42161) + if !ok { + t.Fatalf("expected dataset for chain 42161") + } + if dataset != "arbitrum-one" { + t.Errorf("dataset = %q, want arbitrum-one", dataset) + } +} + +func TestSqdDatasetFromSettings_NonStringValue(t *testing.T) { + settings := common.VendorSettings{ + "datasetByChainId": map[string]interface{}{ + "1": 42, // non-string value + }, + } + + _, ok := sqdDatasetFromSettings(settings, 1) + if ok { + t.Error("expected false for non-string dataset value") + } +} + +func TestSqdUseDefaultDatasets_Coercion(t *testing.T) { + tests := []struct { + name string + value interface{} + expected bool + }{ + {"bool true", true, true}, + {"bool false", false, false}, + {"string true", "true", true}, + {"string false", "false", false}, + {"string yes", "yes", true}, + {"string no", "no", false}, + {"string on", "on", true}, + {"string off", "off", false}, + {"string one", "1", true}, + {"string zero", "0", false}, + {"int one", 1, true}, + {"int zero", 0, false}, + {"int64 one", int64(1), true}, + {"int64 zero", int64(0), false}, + {"float one", float64(1), true}, + {"float zero", float64(0), false}, + {"unknown string defaults false", "maybe", false}, + {"unknown type defaults false", []string{"bad"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sqdUseDefaultDatasets(common.VendorSettings{"useDefaultDatasets": tt.value}) + if got != tt.expected { + t.Fatalf("expected %v, got %v", tt.expected, got) + } + }) + } +} + +func TestSqdUseDefaultDatasets_NilSettings(t *testing.T) { + got := sqdUseDefaultDatasets(nil) + if !got { + t.Fatal("expected true for nil settings") + } +} + +func TestSqdUseDefaultDatasets_NotSet(t *testing.T) { + got := sqdUseDefaultDatasets(common.VendorSettings{}) + if !got { + t.Fatal("expected true when useDefaultDatasets not set") + } +} diff --git a/thirdparty/sqd_test.go b/thirdparty/sqd_test.go new file mode 100644 index 000000000..752db9d80 --- /dev/null +++ b/thirdparty/sqd_test.go @@ -0,0 +1,420 @@ +package thirdparty + +import ( + "context" + "net/http" + "testing" + + "github.com/erpc/erpc/common" + "github.com/erpc/erpc/util" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func init() { + util.ConfigureTestLogger() +} + +func TestSqdVendor_Name(t *testing.T) { + v := CreateSqdVendor() + assert.Equal(t, "sqd", v.Name()) +} + +func TestSqdVendor_OwnsUpstream(t *testing.T) { + v := CreateSqdVendor() + + tests := []struct { + name string + upstream *common.UpstreamConfig + expected bool + }{ + { + name: "vendorName sqd", + upstream: &common.UpstreamConfig{Type: common.UpstreamTypeEvm, VendorName: "sqd"}, + expected: true, + }, + { + name: "endpoint with portal.sqd.dev detected", + upstream: &common.UpstreamConfig{Endpoint: "https://portal.sqd.dev/datasets/ethereum-mainnet"}, + expected: true, + }, + { + name: "other vendor", + upstream: &common.UpstreamConfig{Type: common.UpstreamTypeEvm, VendorName: "alchemy"}, + expected: false, + }, + { + name: "empty config", + upstream: &common.UpstreamConfig{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, v.OwnsUpstream(tt.upstream)) + }) + } +} + +func TestSqdVendor_SupportsNetwork(t *testing.T) { + v := CreateSqdVendor() + ctx := context.Background() + logger := zerolog.Nop() + + tests := []struct { + name string + networkId string + settings common.VendorSettings + expected bool + wantErr bool + }{ + { + name: "ethereum mainnet supported", + networkId: "evm:1", + settings: nil, + expected: true, + }, + { + name: "polygon mainnet supported", + networkId: "evm:137", + settings: nil, + expected: true, + }, + { + name: "default datasets disabled rejects known chain", + networkId: "evm:1", + settings: common.VendorSettings{"useDefaultDatasets": false}, + expected: false, + }, + { + name: "unsupported chainId", + networkId: "evm:999999", + settings: nil, + expected: false, + }, + { + name: "non-evm network not supported", + networkId: "solana:mainnet", + settings: nil, + expected: false, + }, + { + name: "invalid network format", + networkId: "invalid", + settings: nil, + expected: false, + }, + { + name: "evm network with non-numeric chainId", + networkId: "evm:notanumber", + settings: nil, + expected: false, + wantErr: true, + }, + { + name: "unsupported chainId with dataset override", + networkId: "evm:999999", + settings: common.VendorSettings{"dataset": "custom-dataset"}, + expected: true, + }, + { + name: "unsupported chainId with datasetByChainId override", + networkId: "evm:999999", + settings: common.VendorSettings{"datasetByChainId": map[string]interface{}{"999999": "custom-dataset"}}, + expected: true, + }, + { + name: "default datasets disabled with datasetByChainId override", + networkId: "evm:1", + settings: common.VendorSettings{ + "useDefaultDatasets": false, + "datasetByChainId": map[string]interface{}{"1": "ethereum-mainnet"}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + supported, err := v.SupportsNetwork(ctx, &logger, tt.settings, tt.networkId) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expected, supported) + }) + } +} + +func TestSqdVendor_GenerateConfigs(t *testing.T) { + v := CreateSqdVendor() + ctx := context.Background() + logger := zerolog.Nop() + + t.Run("fails when endpoint missing", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + } + + _, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "upstream.endpoint") + }) + + t.Run("preserves pre-defined endpoint", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal-wrapper.internal/v1/evm/1", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.NoError(t, err) + assert.Len(t, configs, 1) + assert.Equal(t, "https://portal-wrapper.internal/v1/evm/1", configs[0].Endpoint) + assert.Equal(t, "sqd", configs[0].VendorName) + assert.Equal(t, common.UpstreamTypeEvm, configs[0].Type) + }) + + t.Run("sets ignoreMethods and allowMethods", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal-wrapper.internal/v1/evm/1", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.NoError(t, err) + assert.Equal(t, []string{"*"}, configs[0].IgnoreMethods) + assert.Contains(t, configs[0].AllowMethods, "eth_getBlockByNumber") + assert.Contains(t, configs[0].AllowMethods, "eth_getLogs") + assert.Contains(t, configs[0].AllowMethods, "trace_block") + }) + + t.Run("sets wrapper api key header", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal-wrapper.internal/v1/evm/1", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, common.VendorSettings{ + "wrapperApiKey": "secret", + }) + assert.NoError(t, err) + assert.Equal(t, "secret", configs[0].JsonRpc.Headers["X-API-Key"]) + }) + + t.Run("sets wrapper api key custom header", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal-wrapper.internal/v1/evm/1", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, common.VendorSettings{ + "wrapperApiKey": "secret", + "wrapperApiKeyHeader": "X-SQD-Key", + }) + assert.NoError(t, err) + assert.Equal(t, "secret", configs[0].JsonRpc.Headers["X-SQD-Key"]) + _, hasDefault := configs[0].JsonRpc.Headers["X-API-Key"] + assert.False(t, hasDefault) + }) + + t.Run("preserves existing wrapper header", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal-wrapper.internal/v1/evm/1", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + JsonRpc: &common.JsonRpcUpstreamConfig{ + Headers: map[string]string{"X-API-Key": "existing"}, + }, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, common.VendorSettings{ + "wrapperApiKey": "secret", + }) + assert.NoError(t, err) + assert.Equal(t, "existing", configs[0].JsonRpc.Headers["X-API-Key"]) + }) + + t.Run("applies dataset placeholder to endpoint", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal.sqd.dev/datasets/{dataset}", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.NoError(t, err) + assert.Equal(t, "https://portal.sqd.dev/datasets/ethereum-mainnet", configs[0].Endpoint) + }) + + t.Run("applies dataset to base endpoint", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal.sqd.dev/datasets", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.NoError(t, err) + assert.Equal(t, "https://portal.sqd.dev/datasets/ethereum-mainnet", configs[0].Endpoint) + }) + + t.Run("applies dataset to base endpoint with trailing slash", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal.sqd.dev/datasets/", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.NoError(t, err) + assert.Equal(t, "https://portal.sqd.dev/datasets/ethereum-mainnet", configs[0].Endpoint) + }) + + t.Run("fails when dataset placeholder unresolved", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal.sqd.dev/datasets/{dataset}", + Evm: &common.EvmUpstreamConfig{ChainId: 999999}, + } + + _, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "{dataset}") + assert.Contains(t, err.Error(), "999999") + }) + + t.Run("preserves user-defined ignoreMethods", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal-wrapper.internal/v1/evm/1", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + IgnoreMethods: []string{"eth_call"}, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.NoError(t, err) + assert.Equal(t, []string{"eth_call"}, configs[0].IgnoreMethods) + }) + + t.Run("preserves user-defined allowMethods", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Endpoint: "https://portal-wrapper.internal/v1/evm/1", + Evm: &common.EvmUpstreamConfig{ChainId: 1}, + AllowMethods: []string{"eth_getBlockByNumber"}, + } + + configs, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.NoError(t, err) + assert.Equal(t, []string{"eth_getBlockByNumber"}, configs[0].AllowMethods) + }) + + t.Run("fails when evm config is nil", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + } + + _, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "upstream.evm") + }) + + t.Run("fails when chainId is zero", func(t *testing.T) { + upstream := &common.UpstreamConfig{ + Id: "test-sqd", + Type: common.UpstreamTypeEvm, + Evm: &common.EvmUpstreamConfig{ChainId: 0}, + } + + _, err := v.GenerateConfigs(ctx, &logger, upstream, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "chainId") + }) +} + +func TestSqdVendor_GetVendorSpecificErrorIfAny(t *testing.T) { + v := CreateSqdVendor().(*SqdVendor) + + tests := []struct { + name string + resp *http.Response + expectNil bool + expectType string + }{ + { + name: "nil response returns nil", + resp: nil, + expectNil: true, + }, + { + name: "200 returns nil", + resp: &http.Response{StatusCode: http.StatusOK}, + expectNil: true, + }, + { + name: "401 returns unauthorized", + resp: &http.Response{StatusCode: http.StatusUnauthorized}, + expectType: "ErrEndpointUnauthorized", + }, + { + name: "403 returns unauthorized", + resp: &http.Response{StatusCode: http.StatusForbidden}, + expectType: "ErrEndpointUnauthorized", + }, + { + name: "429 returns capacity exceeded", + resp: &http.Response{StatusCode: http.StatusTooManyRequests}, + expectType: "ErrEndpointCapacityExceeded", + }, + { + name: "402 returns billing issue", + resp: &http.Response{StatusCode: http.StatusPaymentRequired}, + expectType: "ErrEndpointBillingIssue", + }, + { + name: "500 returns nil (handled by generic normalization)", + resp: &http.Response{StatusCode: http.StatusInternalServerError}, + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := v.GetVendorSpecificErrorIfAny(nil, tt.resp, nil, nil) + if tt.expectNil { + assert.NoError(t, err) + return + } + assert.Error(t, err) + switch tt.expectType { + case "ErrEndpointUnauthorized": + assert.True(t, common.HasErrorCode(err, common.ErrCodeEndpointUnauthorized), "expected ErrEndpointUnauthorized, got %v", err) + case "ErrEndpointCapacityExceeded": + assert.True(t, common.HasErrorCode(err, common.ErrCodeEndpointCapacityExceeded), "expected ErrEndpointCapacityExceeded, got %v", err) + case "ErrEndpointBillingIssue": + assert.True(t, common.HasErrorCode(err, common.ErrCodeEndpointBillingIssue), "expected ErrEndpointBillingIssue, got %v", err) + } + }) + } +} diff --git a/thirdparty/vendors_registry.go b/thirdparty/vendors_registry.go index 4979e6218..42b163e13 100644 --- a/thirdparty/vendors_registry.go +++ b/thirdparty/vendors_registry.go @@ -30,6 +30,7 @@ func NewVendorsRegistry() *VendorsRegistry { r.Register(CreateBlockPiVendor()) r.Register(CreateAnkrVendor()) r.Register(CreateRoutemeshVendor()) + r.Register(CreateSqdVendor()) return r }