diff --git a/cmd/client/eth_client.go b/cmd/client/eth_client.go index 7ee1a785..22076dcc 100644 --- a/cmd/client/eth_client.go +++ b/cmd/client/eth_client.go @@ -183,6 +183,7 @@ func readSethConfigFromFile(configPath string) (*seth.Config, error) { return &sethConfig, nil } +// TODO(DEVSVCS-5178) func getChainID(rpcURL string) (uint64, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index b30352c1..48c9d209 100644 --- a/cmd/secrets/common/handler.go +++ b/cmd/secrets/common/handler.go @@ -19,6 +19,7 @@ import ( "github.com/google/uuid" "github.com/machinebox/graphql" "github.com/rs/zerolog" + "github.com/spf13/viper" "google.golang.org/protobuf/encoding/protojson" "gopkg.in/yaml.v2" @@ -38,6 +39,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/ethkeys" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/types" "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" @@ -67,6 +69,8 @@ type SecretsYamlConfig struct { type Handler struct { Log *zerolog.Logger ClientFactory client.Factory + Viper *viper.Viper + TenantContext *tenantctx.EnvironmentContext SecretsFilePath string PrivateKey *ecdsa.PrivateKey OwnerAddress string @@ -78,6 +82,11 @@ type Handler struct { Credentials *credentials.Credentials Settings *settings.Settings execCtx context.Context + + vaultValidationDecided bool + skipVaultValidation bool + capRegRPCURL string + capRegChainName string } // NewHandler creates a new handler instance. @@ -100,6 +109,8 @@ func NewHandler(execCtx context.Context, ctx *runtime.Context, secretsFilePath, h := &Handler{ Log: ctx.Logger, ClientFactory: ctx.ClientFactory, + Viper: ctx.Viper, + TenantContext: ctx.TenantContext, SecretsFilePath: secretsFilePath, PrivateKey: pk, OwnerAddress: ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress, @@ -427,6 +438,10 @@ func (h *Handler) Execute( defer ZeroUpsertSecretValues(inputs) h.execCtx = ctx + if _, err := h.EnsureVaultValidationOrConsent(ctx); err != nil { + return err + } + if IsBrowserFlow(secretsAuth) { return h.executeBrowserUpsert(ctx, inputs, method) } diff --git a/cmd/secrets/common/vault_validation.go b/cmd/secrets/common/vault_validation.go new file mode 100644 index 00000000..7682a417 --- /dev/null +++ b/cmd/secrets/common/vault_validation.go @@ -0,0 +1,87 @@ +package common + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +const vaultValidationSkippedWarning = "Vault gateway validation skipped; the encryption key and response signatures will not be verified independently of the gateway." + +// EnsureVaultValidationOrConsent resolves CapabilitiesRegistry RPC settings and either +// enables on-chain validation (skipValidation=false) or obtains explicit consent to +// proceed without validation. The result is cached for the lifetime of the Handler so +// encrypt and response parsing in the same command only prompt once. +func (h *Handler) EnsureVaultValidationOrConsent(ctx context.Context) (skipValidation bool, err error) { + _ = ctx + if h.vaultValidationDecided { + return h.skipVaultValidation, nil + } + + rpcURL, chainName, ok, err := settings.ResolveCapabilitiesRegistryRPC(h.Viper, h.TenantContext) + if err != nil { + return false, err + } + + if ok { + h.capRegRPCURL = rpcURL + h.capRegChainName = chainName + h.skipVaultValidation = false + h.vaultValidationDecided = true + return false, nil + } + + if h.Viper.GetBool(settings.Flags.NonInteractive.Name) && !h.Viper.GetBool(settings.Flags.SkipConfirmation.Name) { + ui.ErrorWithSuggestions( + fmt.Sprintf("Vault gateway validation requires an RPC for %s in your project settings", chainName), + []string{"--yes"}, + ) + return false, fmt.Errorf("missing RPC for capabilities registry chain %q", chainName) + } + + if h.Viper.GetBool(settings.Flags.SkipConfirmation.Name) { + ui.Warning(vaultValidationSkippedWarning) + h.capRegChainName = chainName + h.skipVaultValidation = true + h.vaultValidationDecided = true + return true, nil + } + + prompt := fmt.Sprintf( + "Vault gateway responses cannot be validated without an RPC for %s in your project settings. Proceeding without validation means the CLI cannot verify the encryption key or DON signatures independently of the gateway. Proceed anyway?", + chainName, + ) + proceed, err := ui.Confirm(prompt) + if err != nil { + return false, err + } + if !proceed { + return false, fmt.Errorf("aborted: vault gateway validation requires an RPC for %q", chainName) + } + + ui.Warning(vaultValidationSkippedWarning) + h.capRegChainName = chainName + h.skipVaultValidation = true + h.vaultValidationDecided = true + return true, nil +} + +// SkipVaultValidation reports whether the current command opted out of on-chain validation. +func (h *Handler) SkipVaultValidation() bool { + return h.skipVaultValidation +} + +// CapabilitiesRegistryRPC returns the validated RPC URL when validation is enabled. +func (h *Handler) CapabilitiesRegistryRPC() (rpcURL string, ok bool) { + if h.skipVaultValidation || h.capRegRPCURL == "" { + return "", false + } + return h.capRegRPCURL, true +} + +// CapabilitiesRegistryChainName returns the chain name for the tenant CapabilitiesRegistry. +func (h *Handler) CapabilitiesRegistryChainName() string { + return h.capRegChainName +} diff --git a/cmd/secrets/common/vault_validation_test.go b/cmd/secrets/common/vault_validation_test.go new file mode 100644 index 00000000..a0d38e49 --- /dev/null +++ b/cmd/secrets/common/vault_validation_test.go @@ -0,0 +1,118 @@ +package common + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +func testHandlerWithCapReg(t *testing.T, v *viper.Viper, tenantCtx *tenantctx.EnvironmentContext) *Handler { + t.Helper() + h, _, _ := newMockHandler(t) + h.Viper = v + h.TenantContext = tenantCtx + return h +} + +func TestEnsureVaultValidationOrConsent_RPCConfigured(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": "0xaa36a7", + })) + })) + t.Cleanup(server.Close) + + v := viper.New() + v.Set(settings.CreTargetEnvVar, "staging") + v.Set("staging.rpcs", []map[string]string{ + {"chain-name": "ethereum-testnet-sepolia", "url": server.URL}, + }) + + tenantCtx := &tenantctx.EnvironmentContext{ + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: 16015286601757825753, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } + + h := testHandlerWithCapReg(t, v, tenantCtx) + + skip, err := h.EnsureVaultValidationOrConsent(context.Background()) + require.NoError(t, err) + require.False(t, skip) + require.False(t, h.SkipVaultValidation()) + + rpcURL, ok := h.CapabilitiesRegistryRPC() + require.True(t, ok) + require.Equal(t, server.URL, rpcURL) + + skipCached, err := h.EnsureVaultValidationOrConsent(context.Background()) + require.NoError(t, err) + require.False(t, skipCached) +} + +func TestEnsureVaultValidationOrConsent_SkipConfirmationWithoutRPC(t *testing.T) { + v := viper.New() + v.Set(settings.CreTargetEnvVar, "staging") + v.Set(settings.Flags.SkipConfirmation.Name, true) + + tenantCtx := &tenantctx.EnvironmentContext{ + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: 16015286601757825753, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } + + h := testHandlerWithCapReg(t, v, tenantCtx) + + skip, err := h.EnsureVaultValidationOrConsent(context.Background()) + require.NoError(t, err) + require.True(t, skip) + require.True(t, h.SkipVaultValidation()) + _, ok := h.CapabilitiesRegistryRPC() + require.False(t, ok) +} + +func TestEnsureVaultValidationOrConsent_NonInteractiveWithoutRPC(t *testing.T) { + v := viper.New() + v.Set(settings.CreTargetEnvVar, "staging") + v.Set(settings.Flags.NonInteractive.Name, true) + + tenantCtx := &tenantctx.EnvironmentContext{ + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: 16015286601757825753, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } + + h := testHandlerWithCapReg(t, v, tenantCtx) + + _, err := h.EnsureVaultValidationOrConsent(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "missing RPC for capabilities registry chain") +} + +func TestEnsureVaultValidationOrConsent_MissingCapabilitiesRegistry(t *testing.T) { + v := viper.New() + h := testHandlerWithCapReg(t, v, &tenantctx.EnvironmentContext{}) + + _, err := h.EnsureVaultValidationOrConsent(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "capabilities registry is not configured") +} diff --git a/cmd/secrets/delete/delete.go b/cmd/secrets/delete/delete.go index 57126591..56165534 100644 --- a/cmd/secrets/delete/delete.go +++ b/cmd/secrets/delete/delete.go @@ -117,6 +117,10 @@ func New(ctx *runtime.Context) *cobra.Command { // - MSIG step 1: build request, compute digest, write bundle, print steps // - EOA: allowlist if needed, then POST to gateway func Execute(ctx context.Context, h *common.Handler, inputs DeleteSecretsInputs, duration time.Duration, secretsAuth string) error { + if _, err := h.EnsureVaultValidationOrConsent(ctx); err != nil { + return err + } + if !common.IsBrowserFlow(secretsAuth) { if err := h.EnsureDeploymentRPCForOwnerKeySecrets(); err != nil { return err diff --git a/cmd/secrets/execute/execute.go b/cmd/secrets/execute/execute.go index 8c89f303..dcdc95af 100644 --- a/cmd/secrets/execute/execute.go +++ b/cmd/secrets/execute/execute.go @@ -69,6 +69,10 @@ func New(ctx *runtime.Context) *cobra.Command { return err } + if _, err := h.EnsureVaultValidationOrConsent(cmd.Context()); err != nil { + return err + } + ownerAddr := ethcommon.HexToAddress(h.OwnerAddress) allowlisted, err := h.Wrc.IsRequestAllowlisted(cmd.Context(), ownerAddr, digest) @@ -94,6 +98,7 @@ func New(ctx *runtime.Context) *cobra.Command { } settings.AddTxnTypeFlags(cmd) + settings.AddSkipConfirmation(cmd) return cmd } diff --git a/cmd/secrets/list/list.go b/cmd/secrets/list/list.go index 6a4a58b8..bc370c57 100644 --- a/cmd/secrets/list/list.go +++ b/cmd/secrets/list/list.go @@ -87,6 +87,10 @@ func New(ctx *runtime.Context) *cobra.Command { // Execute performs: build request → (MSIG step 1 bundle OR EOA allowlist+post) → parse. func Execute(ctx context.Context, h *common.Handler, namespace string, duration time.Duration, secretsAuth string) error { + if _, err := h.EnsureVaultValidationOrConsent(ctx); err != nil { + return err + } + if !common.IsBrowserFlow(secretsAuth) { if err := h.EnsureDeploymentRPCForOwnerKeySecrets(); err != nil { return err diff --git a/docs/cre_secrets_execute.md b/docs/cre_secrets_execute.md index 464bd617..5a8534dc 100644 --- a/docs/cre_secrets_execute.md +++ b/docs/cre_secrets_execute.md @@ -17,6 +17,7 @@ cre secrets execute 157364...af4d5.json ``` -h, --help help for execute --unsigned If set, the command will either return the raw transaction instead of sending it to the network or execute the second step of secrets operations using a previously generated raw transaction + --yes If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive ``` ### Options inherited from parent commands diff --git a/internal/rpc/chainid.go b/internal/rpc/chainid.go new file mode 100644 index 00000000..aab64d8d --- /dev/null +++ b/internal/rpc/chainid.go @@ -0,0 +1,57 @@ +package rpc + +import ( + "context" + "fmt" + "strconv" + "time" + + gethrpc "github.com/ethereum/go-ethereum/rpc" + + chainSelectors "github.com/smartcontractkit/chain-selectors" +) + +// QueryEthChainID dials rpcURL and returns the chain ID from eth_chainId. +func QueryEthChainID(rpcURL string) (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + client, err := gethrpc.DialContext(ctx, rpcURL) + if err != nil { + return 0, err + } + defer client.Close() + + var chainIDHex string + if err := client.CallContext(ctx, &chainIDHex, "eth_chainId"); err != nil { + return 0, err + } + + return strconv.ParseUint(chainIDHex, 0, 64) +} + +// ValidateMatchesSelector verifies the RPC's eth_chainId matches expectedSelector. +func ValidateMatchesSelector(rpcURL string, expectedSelector uint64) error { + rpcChainID, err := QueryEthChainID(rpcURL) + if err != nil { + return fmt.Errorf("failed to verify RPC chain ID: %w", err) + } + + expectedChainIDRaw, err := chainSelectors.GetChainIDFromSelector(expectedSelector) + if err != nil { + return fmt.Errorf("invalid chain selector %d: %w", expectedSelector, err) + } + expectedChainID, err := strconv.ParseUint(expectedChainIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("invalid chain ID %q for selector %d: %w", expectedChainIDRaw, expectedSelector, err) + } + + if rpcChainID != expectedChainID { + return fmt.Errorf( + "RPC URL points to chain ID %d, but expected chain ID %d (selector %d); check your project RPC settings", + rpcChainID, expectedChainID, expectedSelector, + ) + } + + return nil +} diff --git a/internal/rpc/chainid_test.go b/internal/rpc/chainid_test.go new file mode 100644 index 00000000..68008d95 --- /dev/null +++ b/internal/rpc/chainid_test.go @@ -0,0 +1,60 @@ +package rpc_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/rpc" +) + +const sepoliaChainSelector uint64 = 16015286601757825753 + +func newEthChainIDServer(t *testing.T, chainIDHex string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + require.Equal(t, "eth_chainId", req.Method) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": chainIDHex, + })) + })) +} + +func TestQueryEthChainID(t *testing.T) { + server := newEthChainIDServer(t, "0xaa36a7") + t.Cleanup(server.Close) + + chainID, err := rpc.QueryEthChainID(server.URL) + require.NoError(t, err) + require.Equal(t, uint64(11155111), chainID) +} + +func TestValidateMatchesSelector(t *testing.T) { + t.Run("matching chain ID", func(t *testing.T) { + server := newEthChainIDServer(t, "0xaa36a7") + t.Cleanup(server.Close) + + require.NoError(t, rpc.ValidateMatchesSelector(server.URL, sepoliaChainSelector)) + }) + + t.Run("mismatched chain ID", func(t *testing.T) { + server := newEthChainIDServer(t, "0x1") + t.Cleanup(server.Close) + + err := rpc.ValidateMatchesSelector(server.URL, sepoliaChainSelector) + require.Error(t, err) + require.Contains(t, err.Error(), "RPC URL points to chain ID") + }) +} diff --git a/internal/rpc/url.go b/internal/rpc/url.go new file mode 100644 index 00000000..75fa9cb8 --- /dev/null +++ b/internal/rpc/url.go @@ -0,0 +1,23 @@ +package rpc + +import ( + "fmt" + "net/url" +) + +// IsValidURL checks that rpcURL has an http or https scheme and a non-empty host. +func IsValidURL(rpcURL string) error { + parsedURL, err := url.Parse(rpcURL) + if err != nil { + return fmt.Errorf("failed to parse RPC URL: invalid format") + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return fmt.Errorf("invalid scheme in RPC URL: %s", parsedURL.Scheme) + } + if parsedURL.Host == "" { + return fmt.Errorf("invalid host in RPC URL: %s", parsedURL.Host) + } + + return nil +} diff --git a/internal/rpc/url_test.go b/internal/rpc/url_test.go new file mode 100644 index 00000000..de5c15b3 --- /dev/null +++ b/internal/rpc/url_test.go @@ -0,0 +1,37 @@ +package rpc_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/rpc" +) + +func TestIsValidURL(t *testing.T) { + t.Run("accepts https URL", func(t *testing.T) { + require.NoError(t, rpc.IsValidURL("https://rpc.example.com")) + }) + + t.Run("accepts http URL", func(t *testing.T) { + require.NoError(t, rpc.IsValidURL("http://127.0.0.1:8545")) + }) + + t.Run("rejects invalid scheme", func(t *testing.T) { + err := rpc.IsValidURL("ftp://rpc.example.com") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid scheme") + }) + + t.Run("rejects missing host", func(t *testing.T) { + err := rpc.IsValidURL("https://") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid host") + }) + + t.Run("rejects URL without scheme", func(t *testing.T) { + err := rpc.IsValidURL("not-a-valid-url") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid scheme") + }) +} diff --git a/internal/settings/capabilities_registry_rpc.go b/internal/settings/capabilities_registry_rpc.go new file mode 100644 index 00000000..0c9a3b09 --- /dev/null +++ b/internal/settings/capabilities_registry_rpc.go @@ -0,0 +1,48 @@ +package settings + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" + + "github.com/smartcontractkit/cre-cli/internal/rpc" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +// ResolveCapabilitiesRegistryRPC looks up the project RPC URL for the tenant's +// CapabilitiesRegistry chain. When no RPC is configured, ok is false and err is nil. +// When an RPC is configured, its URL format and eth_chainId are validated against +// the tenant chain selector before returning ok=true. +// +// TODO(DEVSVCS-5178) +func ResolveCapabilitiesRegistryRPC(v *viper.Viper, tenantCtx *tenantctx.EnvironmentContext) (rpcURL, chainName string, ok bool, err error) { + if tenantCtx == nil || tenantCtx.CapabilitiesRegistry == nil { + return "", "", false, fmt.Errorf("capabilities registry is not configured in your user context; run `cre login` to refresh %s", tenantctx.ContextFile) + } + + expectedSelector := tenantCtx.CapabilitiesRegistry.ChainSelector + + chainName, err = GetChainNameByChainSelector(expectedSelector) + if err != nil { + return "", "", false, fmt.Errorf("capabilities registry chain selector %d: %w", expectedSelector, err) + } + + rpcURL, err = GetRpcUrlSettings(v, chainName) + if err != nil { + if strings.Contains(err.Error(), "rpc url not found") { + return "", chainName, false, nil + } + return "", chainName, false, err + } + + if err := rpc.IsValidURL(rpcURL); err != nil { + return "", chainName, false, fmt.Errorf("invalid RPC URL for %s: %w", chainName, err) + } + + if err := rpc.ValidateMatchesSelector(rpcURL, expectedSelector); err != nil { + return "", chainName, false, err + } + + return rpcURL, chainName, true, nil +} diff --git a/internal/settings/capabilities_registry_rpc_test.go b/internal/settings/capabilities_registry_rpc_test.go new file mode 100644 index 00000000..733fbb8b --- /dev/null +++ b/internal/settings/capabilities_registry_rpc_test.go @@ -0,0 +1,130 @@ +package settings_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +const sepoliaChainSelector uint64 = 16015286601757825753 + +func newEthChainIDServer(t *testing.T, chainIDHex string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + require.Equal(t, "eth_chainId", req.Method) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": chainIDHex, + })) + })) +} + +func TestResolveCapabilitiesRegistryRPC_MissingTenantContext(t *testing.T) { + _, _, ok, err := settings.ResolveCapabilitiesRegistryRPC(viper.New(), nil) + require.Error(t, err) + require.False(t, ok) + require.Contains(t, err.Error(), "capabilities registry is not configured") +} + +func TestResolveCapabilitiesRegistryRPC_NoRPCConfigured(t *testing.T) { + v := viper.New() + v.Set(settings.CreTargetEnvVar, "staging") + v.Set("staging.rpcs", []map[string]string{ + {"chain-name": "ethereum-mainnet", "url": "https://example.invalid"}, + }) + + tenantCtx := &tenantctx.EnvironmentContext{ + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: sepoliaChainSelector, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } + + rpcURL, chainName, ok, err := settings.ResolveCapabilitiesRegistryRPC(v, tenantCtx) + require.NoError(t, err) + require.False(t, ok) + require.Empty(t, rpcURL) + require.Equal(t, "ethereum-testnet-sepolia", chainName) +} + +func TestResolveCapabilitiesRegistryRPC_ValidRPC(t *testing.T) { + server := newEthChainIDServer(t, "0xaa36a7") // Sepolia + t.Cleanup(server.Close) + + v := viper.New() + v.Set(settings.CreTargetEnvVar, "staging") + v.Set("staging.rpcs", []map[string]string{ + {"chain-name": "ethereum-testnet-sepolia", "url": server.URL}, + }) + + tenantCtx := &tenantctx.EnvironmentContext{ + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: sepoliaChainSelector, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } + + rpcURL, chainName, ok, err := settings.ResolveCapabilitiesRegistryRPC(v, tenantCtx) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, server.URL, rpcURL) + require.Equal(t, "ethereum-testnet-sepolia", chainName) +} + +func TestResolveCapabilitiesRegistryRPC_WrongChainID(t *testing.T) { + server := newEthChainIDServer(t, "0x1") // mainnet + t.Cleanup(server.Close) + + v := viper.New() + v.Set(settings.CreTargetEnvVar, "staging") + v.Set("staging.rpcs", []map[string]string{ + {"chain-name": "ethereum-testnet-sepolia", "url": server.URL}, + }) + + tenantCtx := &tenantctx.EnvironmentContext{ + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: sepoliaChainSelector, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } + + _, _, ok, err := settings.ResolveCapabilitiesRegistryRPC(v, tenantCtx) + require.Error(t, err) + require.False(t, ok) + require.Contains(t, err.Error(), "RPC URL points to chain ID") +} + +func TestResolveCapabilitiesRegistryRPC_InvalidRPCURL(t *testing.T) { + v := viper.New() + v.Set(settings.CreTargetEnvVar, "staging") + v.Set("staging.rpcs", []map[string]string{ + {"chain-name": "ethereum-testnet-sepolia", "url": "not-a-valid-url"}, + }) + + tenantCtx := &tenantctx.EnvironmentContext{ + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: sepoliaChainSelector, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } + + _, _, ok, err := settings.ResolveCapabilitiesRegistryRPC(v, tenantCtx) + require.Error(t, err) + require.False(t, ok) + require.Contains(t, err.Error(), "invalid RPC URL") +} diff --git a/internal/settings/settings_get.go b/internal/settings/settings_get.go index f83fb79b..cf8541b0 100644 --- a/internal/settings/settings_get.go +++ b/internal/settings/settings_get.go @@ -47,6 +47,9 @@ type ExperimentalChain struct { Forwarder string `mapstructure:"forwarder" yaml:"forwarder"` } +// GetRpcUrlSettings resolves the RPC URL for chainName from the current project target. +// +// TODO(DEVSVCS-5178) func GetRpcUrlSettings(v *viper.Viper, chainName string) (string, error) { target, err := GetTarget(v) if err != nil { diff --git a/internal/settings/workflow_settings.go b/internal/settings/workflow_settings.go index 992a9f3b..17aba9c4 100644 --- a/internal/settings/workflow_settings.go +++ b/internal/settings/workflow_settings.go @@ -2,7 +2,6 @@ package settings import ( "fmt" - "net/url" "os" "strings" @@ -13,6 +12,7 @@ import ( "sigs.k8s.io/yaml" "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/rpc" ) // GetWorkflowPathFromFile reads workflow-path from a workflow.yaml file (same value deploy/simulate get from Settings). @@ -277,20 +277,7 @@ func validateSettings(config *WorkflowSettings, allowUnknownChains bool) error { } func isValidRpcUrl(rpcURL string) error { - parsedURL, err := url.Parse(rpcURL) - if err != nil { - return fmt.Errorf("failed to parse RPC URL: invalid format") - } - - // Check if the URL has a valid scheme and host - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return fmt.Errorf("invalid scheme in RPC URL: %s", parsedURL.Scheme) - } - if parsedURL.Host == "" { - return fmt.Errorf("invalid host in RPC URL: %s", parsedURL.Host) - } - - return nil + return rpc.IsValidURL(rpcURL) } func IsValidChainName(name string) error { @@ -335,6 +322,8 @@ func ShouldSkipGetOwner(cmd *cobra.Command) bool { // ValidateDeploymentRPC ensures project settings define a valid RPC URL for chainName (e.g. the workflow // registry chain). It is a no-op when chainName is empty. Used during settings load and from secrets owner-key flows. +// +// TODO(DEVSVCS-5178) func ValidateDeploymentRPC(config *WorkflowSettings, chainName string) error { if chainName == "" { return nil diff --git a/test/multi_command_flows/secrets_happy_path.go b/test/multi_command_flows/secrets_happy_path.go index 71974060..b8ce7f1a 100644 --- a/test/multi_command_flows/secrets_happy_path.go +++ b/test/multi_command_flows/secrets_happy_path.go @@ -315,6 +315,7 @@ func secretsListMsig(t *testing.T, tc TestConfig) string { tc.GetCliEnvFlag(), tc.GetProjectRootFlag(), "--unsigned", + "--" + settings.Flags.SkipConfirmation.Name, } cmd := exec.Command(CLIPath, args...) // Let CLI handle context switching - don't set cmd.Dir manually