diff --git a/.github/workflows/pull-request-main.yml b/.github/workflows/pull-request-main.yml index 9b33355b..deb5e154 100644 --- a/.github/workflows/pull-request-main.yml +++ b/.github/workflows/pull-request-main.yml @@ -39,9 +39,18 @@ jobs: contents: read actions: read steps: + - name: Checkout the repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: ci-lint-misc uses: smartcontractkit/.github/actions/ci-lint-misc@01d931b0455a754d12e7143cc54a5a3521a8f6f6 # ci-lint-misc@0.1.4 + - name: Verify install.sh embedded release public key + run: bash install/check_embedded_key.sh + + - name: Verify install.sh linux asset suffix helpers + run: bash install/linux_asset_suffix_test.sh + ci-test-unit: runs-on: ubuntu-latest permissions: diff --git a/cmd/client/eth_client.go b/cmd/client/eth_client.go index e0e2a154..04be9d97 100644 --- a/cmd/client/eth_client.go +++ b/cmd/client/eth_client.go @@ -22,6 +22,7 @@ import ( "github.com/smartcontractkit/chainlink-testing-framework/seth" "github.com/smartcontractkit/cre-cli/internal/constants" + crpc "github.com/smartcontractkit/cre-cli/internal/rpc" "github.com/smartcontractkit/cre-cli/internal/settings" ) @@ -76,6 +77,15 @@ func NewEthClientFromEnv(v *viper.Viper, l *zerolog.Logger, ethUrl string) (*set // check configuration file then use default value sethConfigPath := v.GetString(settings.SethConfigPathSettingName) + cleartextOpts := crpc.CleartextPolicyOptions{ + AllowInsecure: v.GetBool(settings.Flags.AllowInsecureRPC.Name), + } + if warnMsg, blockErr := crpc.EvaluateCleartextRPC(ethUrl, cleartextOpts); blockErr != nil { + return nil, blockErr + } else if warnMsg != "" { + l.Warn().Str("url", crpc.RedactURL(ethUrl)).Msg(warnMsg) + } + ethChainID, err := getChainID(ethUrl) if err != nil { return nil, fmt.Errorf("failed to get chain ID: %w", err) @@ -97,7 +107,7 @@ func NewEthClientFromEnv(v *viper.Viper, l *zerolog.Logger, ethUrl string) (*set if err != nil { return nil, fmt.Errorf("failed to create Seth client: %w", err) } - l.Debug().Int64("ChainID", client.ChainID).Str("URL", client.URL).Msg("Connected to a RPC node") + l.Debug().Int64("ChainID", client.ChainID).Str("URL", crpc.RedactURL(client.URL)).Msg("Connected to a RPC node") l.Debug().Msg("Loading contract interfaces") err = LoadContracts(l, client) diff --git a/cmd/client/eth_client_cleartext_test.go b/cmd/client/eth_client_cleartext_test.go new file mode 100644 index 00000000..730ed9cf --- /dev/null +++ b/cmd/client/eth_client_cleartext_test.go @@ -0,0 +1,36 @@ +package client_test + +import ( + "testing" + + "github.com/rs/zerolog" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/cmd/client" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func TestNewEthClientFromEnvBlocksRemoteCleartext(t *testing.T) { + t.Parallel() + + v := viper.New() + v.Set(settings.Flags.AllowInsecureRPC.Name, false) + + logger := zerolog.Nop() + _, err := client.NewEthClientFromEnv(v, &logger, "http://rpc.example.com") + require.Error(t, err) + require.Contains(t, err.Error(), "--allow-insecure-rpc") +} + +func TestNewEthClientFromEnvAllowsCleartextWithOptIn(t *testing.T) { + t.Parallel() + + v := viper.New() + v.Set(settings.Flags.AllowInsecureRPC.Name, true) + + logger := zerolog.Nop() + _, err := client.NewEthClientFromEnv(v, &logger, "http://rpc.example.com") + require.Error(t, err) + require.NotContains(t, err.Error(), "--allow-insecure-rpc") +} diff --git a/cmd/common/compile_test.go b/cmd/common/compile_test.go index fa4aa42c..51aa0edb 100644 --- a/cmd/common/compile_test.go +++ b/cmd/common/compile_test.go @@ -119,7 +119,7 @@ func TestCompileWorkflowToWasm_TS_Success(t *testing.T) { mainPath := filepath.Join(dir, "main.ts") require.NoError(t, os.WriteFile(mainPath, []byte(`export async function main() { return "ok"; } `), 0600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test","dependencies":{"@chainlink/cre-sdk":"^1.9.0"}} + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test","dependencies":{"@chainlink/cre-sdk":"1.17.0-alpha.solana-log-trigger.1"}} `), 0600)) install := exec.Command("bun", "install") install.Dir = dir diff --git a/cmd/creinit/creinit.go b/cmd/creinit/creinit.go index ab2760de..ce35365d 100644 --- a/cmd/creinit/creinit.go +++ b/cmd/creinit/creinit.go @@ -3,7 +3,6 @@ package creinit import ( "fmt" "maps" - "net/url" "os" "path/filepath" "strings" @@ -14,6 +13,7 @@ import ( "golang.org/x/term" "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/rpc" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/templateconfig" @@ -348,10 +348,14 @@ func (h *handler) Execute(inputs Inputs) error { maps.Copy(networkRPCs, inputs.RpcURLs) // Validate any provided RPC URLs for chain, rpcURL := range networkRPCs { - if rpcURL != "" { - if u, parseErr := url.Parse(rpcURL); parseErr != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return fmt.Errorf("invalid RPC URL for %s: must be a valid http/https URL", chain) - } + if rpcURL == "" { + continue + } + if err := rpc.IsValidURL(rpcURL); err != nil { + return fmt.Errorf("invalid RPC URL for %s: %w", chain, err) + } + if _, blockErr := rpc.EvaluateCleartextRPC(rpcURL, rpc.CleartextPolicyOptions{}); blockErr != nil { + return fmt.Errorf("invalid RPC URL for %s: %w", chain, blockErr) } } diff --git a/cmd/creinit/wizard.go b/cmd/creinit/wizard.go index 7b42de8c..ebd0b399 100644 --- a/cmd/creinit/wizard.go +++ b/cmd/creinit/wizard.go @@ -3,7 +3,6 @@ package creinit import ( "fmt" "io" - "net/url" "os" "path/filepath" "slices" @@ -15,6 +14,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/rpc" "github.com/smartcontractkit/cre-cli/internal/templaterepo" "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" @@ -707,7 +707,7 @@ func (m wizardModel) handleEnter(msgs ...tea.Msg) (tea.Model, tea.Cmd) { network := m.networks[m.rpcCursor] if value != "" { - if err := validateRpcURL(value); err != nil { + if err := rpc.IsValidURL(value); err != nil { m.err = fmt.Sprintf("Invalid URL for %s: %s", network, err.Error()) return m, nil } @@ -907,7 +907,7 @@ func (m wizardModel) View() string { b.WriteString("\n") // Real-time validation hint for RPC URL if v := strings.TrimSpace(m.rpcInputs[i].Value()); v != "" { - if err := validateRpcURL(v); err != nil { + if err := rpc.IsValidURL(v); err != nil { b.WriteString(m.warnStyle.Render(" " + err.Error())) b.WriteString("\n") } @@ -1007,18 +1007,3 @@ func MissingNetworks(template *templaterepo.TemplateSummary, flagRpcURLs map[str } return missing } - -// validateRpcURL validates that a URL is a valid HTTP/HTTPS URL. -func validateRpcURL(rawURL string) error { - u, err := url.Parse(rawURL) - if err != nil { - return fmt.Errorf("invalid URL format") - } - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("URL must start with http:// or https://") - } - if u.Host == "" { - return fmt.Errorf("URL must have a host") - } - return nil -} diff --git a/cmd/execution/events/events.go b/cmd/execution/events/events.go new file mode 100644 index 00000000..f58569e7 --- /dev/null +++ b/cmd/execution/events/events.go @@ -0,0 +1,124 @@ +package events + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" +) + +// Inputs holds resolved and validated flag/arg values for execution events. +type Inputs struct { + ExecutionRef string + CapabilityID *string + Status *string + OutputFormat string +} + +func resolveInputs(executionRef, capabilityID, status, outputFormat string, jsonFlag bool) (Inputs, error) { + outputFormat, err := workflowresolve.ResolveOutputFormat(outputFormat, jsonFlag) + if err != nil { + return Inputs{}, err + } + in := Inputs{ + ExecutionRef: executionRef, + OutputFormat: outputFormat, + } + if capabilityID != "" { + in.CapabilityID = &capabilityID + } + if status != "" { + in.Status = &status + } + return in, nil +} + +// Handler fetches and renders execution capability events. +type Handler struct { + credentials *credentials.Credentials + wdc *workflowdataclient.Client +} + +// NewHandler builds a Handler with a real WorkflowDataClient. +func NewHandler(ctx *runtime.Context) *Handler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// NewHandlerWithClient builds a Handler with a pre-built client (for testing). +func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// Execute fetches and renders execution events. +func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + spinner := ui.NewSpinner() + spinner.Start("Fetching execution events...") + + uuid, err := workflowresolve.ResolveExecutionUUID(ctx, h.wdc, inputs.ExecutionRef) + if err != nil { + spinner.Stop() + return err + } + + events, err := h.wdc.ListExecutionEvents(ctx, workflowdataclient.ListEventsInput{ + ExecutionUUID: uuid, + CapabilityID: inputs.CapabilityID, + Status: inputs.Status, + }) + spinner.Stop() + if err != nil { + return err + } + + if inputs.OutputFormat == workflowresolve.OutputFormatJSON { + return workflowresolve.PrintEventsJSON(events) + } + workflowresolve.PrintEventsTable(events) + return nil +} + +// New returns the cobra command. +func New(runtimeContext *runtime.Context) *cobra.Command { + var capabilityID string + var status string + var outputFormat string + var jsonFlag bool + + cmd := &cobra.Command{ + Use: "events ", + Short: "Show the node/capability event timeline for an execution", + Long: `Fetch and display the ordered sequence of capability events for a workflow +execution, including per-event status, method, duration, and any errors.`, + Example: "cre execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g\n" + + " cre execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --capability fetch-price\n" + + " cre execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --status FAILURE\n" + + " cre execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inputs, err := resolveInputs(args[0], capabilityID, status, outputFormat, jsonFlag) + if err != nil { + return err + } + return NewHandler(runtimeContext).Execute(cmd.Context(), inputs) + }, + } + + cmd.Flags().StringVar(&capabilityID, "capability", "", "Filter events to a specific capability ID") + cmd.Flags().StringVar(&status, "status", "", "Filter events by status (e.g. FAILURE)") + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + return cmd +} diff --git a/cmd/execution/events/events_test.go b/cmd/execution/events/events_test.go new file mode 100644 index 00000000..c26bf15b --- /dev/null +++ b/cmd/execution/events/events_test.go @@ -0,0 +1,109 @@ +package events_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + execEvents "github.com/smartcontractkit/cre-cli/cmd/execution/events" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func nopLogger() *zerolog.Logger { l := zerolog.Nop(); return &l } + +func wdcFor(t *testing.T, serverURL string) *workflowdataclient.Client { + t.Helper() + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + env := &environments.EnvironmentSet{GraphQLURL: serverURL} + gql := graphqlclient.New(creds, env, nopLogger()) + return workflowdataclient.New(gql, nopLogger()) +} + +func rtCtxFor(t *testing.T, serverURL string) *runtime.Context { + t.Helper() + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + env := &environments.EnvironmentSet{GraphQLURL: serverURL} + return &runtime.Context{Logger: nopLogger(), Credentials: creds, EnvironmentSet: env} +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func gqlRespond(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": payload}) +} + +func TestEvents_MissingCredentials(t *testing.T) { + t.Parallel() + ctx := &runtime.Context{Logger: nopLogger()} + h := execEvents.NewHandlerWithClient(ctx, wdcFor(t, "http://unused")) + err := h.Execute(context.Background(), execEvents.Inputs{ExecutionRef: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "credentials not available") +} + +func TestEvents_JSON(t *testing.T) { + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + finished := time.Date(2026, 5, 29, 14, 0, 7, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutionEvents": map[string]any{ + "data": []any{ + map[string]any{ + "capabilityID": "FetchData", + "status": "SUCCESS", + "method": "GET", + "startedAt": started.Format(time.RFC3339), + "finishedAt": finished.Format(time.RFC3339), + "errors": []any{}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + h := execEvents.NewHandlerWithClient(rtCtxFor(t, srv.URL), wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execEvents.Inputs{ + ExecutionRef: "05ace5cf-85ae-448b-9f42-270d42974d35", + OutputFormat: "json", + }) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.Len(t, result, 1) + assert.Equal(t, "FetchData", result[0]["capabilityID"]) + assert.Equal(t, "GET", result[0]["method"]) + assert.Equal(t, "2s", result[0]["duration"]) +} diff --git a/cmd/execution/execution.go b/cmd/execution/execution.go new file mode 100644 index 00000000..650377df --- /dev/null +++ b/cmd/execution/execution.go @@ -0,0 +1,27 @@ +package execution + +import ( + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/cmd/execution/events" + "github.com/smartcontractkit/cre-cli/cmd/execution/list" + "github.com/smartcontractkit/cre-cli/cmd/execution/logs" + "github.com/smartcontractkit/cre-cli/cmd/execution/status" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +// New returns the top-level `execution` command group wired under `cre`. +func New(runtimeContext *runtime.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "execution", + Short: "Query workflow execution history", + Long: `The execution command provides visibility into workflow executions, node events, and logs.`, + } + + cmd.AddCommand(list.New(runtimeContext)) + cmd.AddCommand(status.New(runtimeContext)) + cmd.AddCommand(events.New(runtimeContext)) + cmd.AddCommand(logs.New(runtimeContext)) + + return cmd +} diff --git a/cmd/execution/list/list.go b/cmd/execution/list/list.go new file mode 100644 index 00000000..65688c8d --- /dev/null +++ b/cmd/execution/list/list.go @@ -0,0 +1,199 @@ +package list + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" +) + +// Inputs holds resolved and validated flag/arg values for execution list. +type Inputs struct { + // WorkflowRef is an optional workflow name or on-chain WorkflowId from the positional arg. + WorkflowRef string + Statuses []workflowdataclient.ExecutionStatus + From *time.Time + To *time.Time + Limit int + OutputFormat string + NonInteractive bool +} + +func resolveInputs( + workflowRef string, + statusFlag, startFlag, endFlag string, + limit int, + outputFormat string, + jsonFlag bool, + nonInteractive bool, +) (Inputs, error) { + outputFormat, err := workflowresolve.ResolveOutputFormat(outputFormat, jsonFlag) + if err != nil { + return Inputs{}, err + } + + var statuses []workflowdataclient.ExecutionStatus + if statusFlag != "" { + s := workflowdataclient.ExecutionStatus(strings.ToUpper(statusFlag)) + if err := validateExecutionStatus(s); err != nil { + return Inputs{}, err + } + statuses = []workflowdataclient.ExecutionStatus{s} + } + + var from, to *time.Time + if startFlag != "" { + t, err := time.Parse(time.RFC3339, startFlag) + if err != nil { + return Inputs{}, fmt.Errorf("--start: invalid ISO8601 datetime %q (expected e.g. 2006-01-02T15:04:05Z)", startFlag) + } + from = &t + } + if endFlag != "" { + t, err := time.Parse(time.RFC3339, endFlag) + if err != nil { + return Inputs{}, fmt.Errorf("--end: invalid ISO8601 datetime %q (expected e.g. 2006-01-02T15:04:05Z)", endFlag) + } + to = &t + } + + return Inputs{ + WorkflowRef: workflowRef, + Statuses: statuses, + From: from, + To: to, + Limit: limit, + OutputFormat: outputFormat, + NonInteractive: nonInteractive, + }, nil +} + +// Handler fetches and renders workflow executions. +type Handler struct { + credentials *credentials.Credentials + wdc *workflowdataclient.Client +} + +// NewHandler builds a Handler with a real WorkflowDataClient. +func NewHandler(ctx *runtime.Context) *Handler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// NewHandlerWithClient builds a Handler with a pre-built client (for testing). +func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// Execute lists executions applying filters from inputs. +func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + var workflowUUID *string + if inputs.WorkflowRef != "" { + uuid, err := workflowresolve.ResolveWorkflowUUID(ctx, h.wdc, inputs.WorkflowRef, workflowresolve.ResolveOptions{ + NonInteractive: inputs.NonInteractive, + }) + if err != nil { + return err + } + workflowUUID = &uuid + } + + spinner := ui.NewSpinner() + spinner.Start("Fetching executions...") + rows, err := h.wdc.ListExecutions(ctx, workflowdataclient.ListExecutionsInput{ + WorkflowUUID: workflowUUID, + Statuses: inputs.Statuses, + From: inputs.From, + To: inputs.To, + Limit: inputs.Limit, + }) + spinner.Stop() + if err != nil { + return err + } + + if inputs.OutputFormat == workflowresolve.OutputFormatJSON { + return workflowresolve.PrintExecutionsJSON(rows) + } + workflowresolve.PrintExecutionsTable(rows) + return nil +} + +// New returns the cobra command. +func New(runtimeContext *runtime.Context) *cobra.Command { + var statusFlag string + var startFlag string + var endFlag string + var limit int + var outputFormat string + var jsonFlag bool + + cmd := &cobra.Command{ + Use: "list [workflow-id-or-name]", + Short: "List recent executions for a workflow", + Long: `List workflow executions from the CRE platform. + +The optional argument accepts either an on-chain Workflow ID (64-char hex, +visible in 'cre workflow list') or a workflow name. When omitted, executions +across all workflows are returned.`, + Example: "cre execution list\n" + + " cre execution list my-workflow\n" + + " cre execution list 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856\n" + + " cre execution list my-workflow --status FAILURE\n" + + " cre execution list my-workflow --start 2026-01-01T00:00:00Z --end 2026-01-02T00:00:00Z\n" + + " cre execution list my-workflow --limit 50 --output json", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + workflowRef := "" + if len(args) == 1 { + workflowRef = args[0] + } + nonInteractive := false + if runtimeContext.Viper != nil { + nonInteractive = runtimeContext.Viper.GetBool(settings.Flags.NonInteractive.Name) + } + inputs, err := resolveInputs(workflowRef, statusFlag, startFlag, endFlag, limit, outputFormat, jsonFlag, nonInteractive) + if err != nil { + return err + } + return NewHandler(runtimeContext).Execute(cmd.Context(), inputs) + }, + } + + cmd.Flags().StringVar(&statusFlag, "status", "", "Filter by execution status (TRIGGERED, IN_PROGRESS, SUCCESS, FAILURE)") + cmd.Flags().StringVar(&startFlag, "start", "", "Start of time range in ISO8601 format (e.g. 2026-01-01T00:00:00Z)") + cmd.Flags().StringVar(&endFlag, "end", "", "End of time range in ISO8601 format (e.g. 2026-01-02T00:00:00Z)") + cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of executions to return (max 100)") + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + + return cmd +} + +func validateExecutionStatus(s workflowdataclient.ExecutionStatus) error { + for _, v := range workflowdataclient.ValidExecutionStatuses { + if s == v { + return nil + } + } + valid := make([]string, len(workflowdataclient.ValidExecutionStatuses)) + for i, v := range workflowdataclient.ValidExecutionStatuses { + valid[i] = string(v) + } + return fmt.Errorf("--status %q is not valid; accepted values: %s", s, strings.Join(valid, ", ")) +} diff --git a/cmd/execution/list/list_test.go b/cmd/execution/list/list_test.go new file mode 100644 index 00000000..a87594f5 --- /dev/null +++ b/cmd/execution/list/list_test.go @@ -0,0 +1,233 @@ +package list_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/cmd/execution" + execList "github.com/smartcontractkit/cre-cli/cmd/execution/list" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func nopLogger() *zerolog.Logger { l := zerolog.Nop(); return &l } + +func credsAndEnv(serverURL string) (*credentials.Credentials, *environments.EnvironmentSet) { + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + env := &environments.EnvironmentSet{GraphQLURL: serverURL} + return creds, env +} + +func wdcFor(t *testing.T, serverURL string) *workflowdataclient.Client { + t.Helper() + creds, env := credsAndEnv(serverURL) + gql := graphqlclient.New(creds, env, nopLogger()) + return workflowdataclient.New(gql, nopLogger()) +} + +func rtCtxFor(t *testing.T, serverURL string) *runtime.Context { + t.Helper() + creds, env := credsAndEnv(serverURL) + return &runtime.Context{ + Logger: nopLogger(), + Credentials: creds, + EnvironmentSet: env, + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func gqlRespond(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": payload}) +} + +func TestList_MissingCredentials(t *testing.T) { + t.Parallel() + ctx := &runtime.Context{Logger: nopLogger()} + h := execList.NewHandlerWithClient(ctx, wdcFor(t, "http://unused")) + err := h.Execute(context.Background(), execList.Inputs{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "credentials not available") +} + +func TestList_InvalidStatus(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflows": map[string]any{"data": []any{}, "count": 0}, + }) + })) + t.Cleanup(srv.Close) + + cmd := execution.New(rtCtxFor(t, srv.URL)) + cmd.SetArgs([]string{"list", "--status", "RUNNING"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "RUNNING") + assert.Contains(t, err.Error(), "not valid") +} + +func TestList_JSONOutput(t *testing.T) { + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + finished := time.Date(2026, 5, 29, 14, 0, 17, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "exec-uuid-1", + "workflowUUID": "wf-uuid-1", + "workflowName": "my-workflow", + "status": "FAILURE", + "startedAt": started.Format(time.RFC3339), + "finishedAt": finished.Format(time.RFC3339), + "creditUsed": "0.05", + "errors": []any{}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execList.NewHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execList.Inputs{OutputFormat: "json"}) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.Len(t, result, 1) + assert.Equal(t, "exec-uuid-1", result[0]["uuid"]) + assert.Equal(t, "FAILURE", result[0]["status"]) + assert.Equal(t, "0.05", result[0]["creditUsed"]) +} + +func TestList_ByName_ResolvesActiveWorkflow(t *testing.T) { + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + query, _ := body["query"].(string) + + if strings.Contains(query, "ListWorkflows") { + gqlRespond(w, map[string]any{ + "workflows": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "wf-uuid-active", + "name": "my-workflow", + "workflowId": "abc123onchain", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }) + return + } + + gqlRespond(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "exec-uuid-1", + "workflowUUID": "wf-uuid-active", + "workflowName": "my-workflow", + "status": "SUCCESS", + "startedAt": started.Format(time.RFC3339), + "errors": []any{}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execList.NewHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execList.Inputs{ + WorkflowRef: "my-workflow", + OutputFormat: "json", + }) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.Len(t, result, 1) + assert.Equal(t, "wf-uuid-active", result[0]["workflowUUID"]) +} + +func TestList_NoArg_ListsAll(t *testing.T) { + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 2, + "data": []any{ + map[string]any{ + "uuid": "exec-1", "workflowUUID": "wf-1", "workflowName": "alpha", + "status": "SUCCESS", "startedAt": started.Format(time.RFC3339), "errors": []any{}, + }, + map[string]any{ + "uuid": "exec-2", "workflowUUID": "wf-2", "workflowName": "beta", + "status": "FAILURE", "startedAt": started.Format(time.RFC3339), "errors": []any{}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execList.NewHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execList.Inputs{OutputFormat: "json"}) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Len(t, result, 2) +} diff --git a/cmd/execution/logs/logs.go b/cmd/execution/logs/logs.go new file mode 100644 index 00000000..193f0481 --- /dev/null +++ b/cmd/execution/logs/logs.go @@ -0,0 +1,111 @@ +package logs + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" +) + +// Inputs holds resolved and validated flag/arg values for execution logs. +type Inputs struct { + ExecutionRef string + // NodeFilter is applied client-side; the API has no server-side node filter. + NodeFilter string + OutputFormat string +} + +func resolveInputs(executionRef, nodeFilter, outputFormat string, jsonFlag bool) (Inputs, error) { + outputFormat, err := workflowresolve.ResolveOutputFormat(outputFormat, jsonFlag) + if err != nil { + return Inputs{}, err + } + return Inputs{ + ExecutionRef: executionRef, + NodeFilter: nodeFilter, + OutputFormat: outputFormat, + }, nil +} + +// Handler fetches and renders execution logs. +type Handler struct { + credentials *credentials.Credentials + wdc *workflowdataclient.Client +} + +// NewHandler builds a Handler with a real WorkflowDataClient. +func NewHandler(ctx *runtime.Context) *Handler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// NewHandlerWithClient builds a Handler with a pre-built client (for testing). +func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// Execute fetches and renders execution logs. +func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + spinner := ui.NewSpinner() + spinner.Start("Fetching execution logs...") + + uuid, err := workflowresolve.ResolveExecutionUUID(ctx, h.wdc, inputs.ExecutionRef) + if err != nil { + spinner.Stop() + return err + } + + logs, err := h.wdc.ListExecutionLogs(ctx, uuid) + spinner.Stop() + if err != nil { + return err + } + + if inputs.OutputFormat == workflowresolve.OutputFormatJSON { + return workflowresolve.PrintLogsJSON(logs, inputs.NodeFilter) + } + workflowresolve.PrintLogsTable(logs, inputs.NodeFilter) + return nil +} + +// New returns the cobra command. +func New(runtimeContext *runtime.Context) *cobra.Command { + var nodeFilter string + var outputFormat string + var jsonFlag bool + + cmd := &cobra.Command{ + Use: "logs ", + Short: "Show logs emitted during a workflow execution", + Long: `Fetch and display all log lines emitted during a workflow execution. +Use --node to filter to a specific capability node (client-side filter).`, + Example: "cre execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g\n" + + " cre execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --node ProcessData\n" + + " cre execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inputs, err := resolveInputs(args[0], nodeFilter, outputFormat, jsonFlag) + if err != nil { + return err + } + return NewHandler(runtimeContext).Execute(cmd.Context(), inputs) + }, + } + + cmd.Flags().StringVar(&nodeFilter, "node", "", "Filter logs to a specific node/capability ID (case-insensitive)") + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + return cmd +} diff --git a/cmd/execution/logs/logs_test.go b/cmd/execution/logs/logs_test.go new file mode 100644 index 00000000..e5e1dd75 --- /dev/null +++ b/cmd/execution/logs/logs_test.go @@ -0,0 +1,134 @@ +package logs_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + execLogs "github.com/smartcontractkit/cre-cli/cmd/execution/logs" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func nopLogger() *zerolog.Logger { l := zerolog.Nop(); return &l } + +func wdcFor(t *testing.T, serverURL string) *workflowdataclient.Client { + t.Helper() + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + env := &environments.EnvironmentSet{GraphQLURL: serverURL} + gql := graphqlclient.New(creds, env, nopLogger()) + return workflowdataclient.New(gql, nopLogger()) +} + +func rtCtxFor(t *testing.T, serverURL string) *runtime.Context { + t.Helper() + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + env := &environments.EnvironmentSet{GraphQLURL: serverURL} + return &runtime.Context{Logger: nopLogger(), Credentials: creds, EnvironmentSet: env} +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func gqlRespond(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": payload}) +} + +func TestLogs_MissingCredentials(t *testing.T) { + t.Parallel() + ctx := &runtime.Context{Logger: nopLogger()} + h := execLogs.NewHandlerWithClient(ctx, wdcFor(t, "http://unused")) + err := h.Execute(context.Background(), execLogs.Inputs{ExecutionRef: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "credentials not available") +} + +func TestLogs_NodeFilter_ClientSide(t *testing.T) { + ts := time.Date(2026, 5, 29, 14, 0, 8, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutionLogs": map[string]any{ + "data": []any{ + map[string]any{"nodeID": "ProcessData", "message": "Starting transformation", "timestamp": ts.Format(time.RFC3339)}, + map[string]any{"nodeID": "FetchData", "message": "HTTP GET called", "timestamp": ts.Format(time.RFC3339)}, + map[string]any{"nodeID": "ProcessData", "message": "Failed to parse", "timestamp": ts.Format(time.RFC3339)}, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + h := execLogs.NewHandlerWithClient(rtCtxFor(t, srv.URL), wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execLogs.Inputs{ + ExecutionRef: "05ace5cf-85ae-448b-9f42-270d42974d35", + NodeFilter: "ProcessData", + OutputFormat: "json", + }) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.Len(t, result, 2) + for _, row := range result { + assert.Equal(t, "ProcessData", row["nodeID"]) + } +} + +func TestLogs_NoFilter_ReturnsAll(t *testing.T) { + ts := time.Date(2026, 5, 29, 14, 0, 8, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutionLogs": map[string]any{ + "data": []any{ + map[string]any{"nodeID": "A", "message": "msg1", "timestamp": ts.Format(time.RFC3339)}, + map[string]any{"nodeID": "B", "message": "msg2", "timestamp": ts.Format(time.RFC3339)}, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + h := execLogs.NewHandlerWithClient(rtCtxFor(t, srv.URL), wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execLogs.Inputs{ + ExecutionRef: "05ace5cf-85ae-448b-9f42-270d42974d35", + OutputFormat: "json", + }) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Len(t, result, 2) +} diff --git a/cmd/execution/status/status.go b/cmd/execution/status/status.go new file mode 100644 index 00000000..6a76ef2f --- /dev/null +++ b/cmd/execution/status/status.go @@ -0,0 +1,126 @@ +package status + +import ( + "context" + "fmt" + "sync" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" +) + +// Inputs holds resolved and validated flag/arg values for execution status. +type Inputs struct { + ExecutionRef string + OutputFormat string +} + +func resolveInputs(executionRef, outputFormat string, jsonFlag bool) (Inputs, error) { + outputFormat, err := workflowresolve.ResolveOutputFormat(outputFormat, jsonFlag) + if err != nil { + return Inputs{}, err + } + return Inputs{ExecutionRef: executionRef, OutputFormat: outputFormat}, nil +} + +// Handler fetches and renders a single execution detail view. +type Handler struct { + credentials *credentials.Credentials + wdc *workflowdataclient.Client +} + +// NewHandler builds a Handler with a real WorkflowDataClient. +func NewHandler(ctx *runtime.Context) *Handler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// NewHandlerWithClient builds a Handler with a pre-built client (for testing). +func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// Execute fetches and renders execution status. +func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + spinner := ui.NewSpinner() + spinner.Start("Fetching execution...") + + uuid, err := workflowresolve.ResolveExecutionUUID(ctx, h.wdc, inputs.ExecutionRef) + if err != nil { + spinner.Stop() + return err + } + + var ( + exec *workflowdataclient.Execution + failEvents []workflowdataclient.ExecutionEvent + execErr error + wg sync.WaitGroup + ) + + wg.Add(1) + go func() { + defer wg.Done() + exec, execErr = h.wdc.GetExecution(ctx, uuid) + }() + wg.Wait() + + if execErr == nil && exec.Status == workflowdataclient.ExecutionStatusFailure { + failEvents, err = h.wdc.ListExecutionEvents(ctx, workflowdataclient.ListEventsInput{ + ExecutionUUID: uuid, + Status: new(string(workflowdataclient.ExecutionStatusFailure)), + }) + if err != nil { + return err + } + } + + spinner.Stop() + if execErr != nil { + return execErr + } + + if inputs.OutputFormat == workflowresolve.OutputFormatJSON { + return workflowresolve.PrintExecutionDetailJSON(*exec, failEvents) + } + workflowresolve.PrintExecutionDetailTable(*exec, failEvents) + return nil +} + +// New returns the cobra command. +func New(runtimeContext *runtime.Context) *cobra.Command { + var outputFormat string + var jsonFlag bool + + cmd := &cobra.Command{ + Use: "status ", + Short: "Show detailed status of a single execution", + Long: `Fetch and display the full status of a workflow execution, including +top-level errors when the execution has failed.`, + Example: "cre execution status 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g\n" + + " cre execution status 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inputs, err := resolveInputs(args[0], outputFormat, jsonFlag) + if err != nil { + return err + } + return NewHandler(runtimeContext).Execute(cmd.Context(), inputs) + }, + } + + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints JSON to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + return cmd +} diff --git a/cmd/execution/status/status_test.go b/cmd/execution/status/status_test.go new file mode 100644 index 00000000..556e79e4 --- /dev/null +++ b/cmd/execution/status/status_test.go @@ -0,0 +1,163 @@ +package status_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + execStatus "github.com/smartcontractkit/cre-cli/cmd/execution/status" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func nopLogger() *zerolog.Logger { l := zerolog.Nop(); return &l } + +func wdcFor(t *testing.T, serverURL string) *workflowdataclient.Client { + t.Helper() + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + env := &environments.EnvironmentSet{GraphQLURL: serverURL} + gql := graphqlclient.New(creds, env, nopLogger()) + return workflowdataclient.New(gql, nopLogger()) +} + +func rtCtxFor(t *testing.T, serverURL string) *runtime.Context { + t.Helper() + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + env := &environments.EnvironmentSet{GraphQLURL: serverURL} + return &runtime.Context{Logger: nopLogger(), Credentials: creds, EnvironmentSet: env} +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func gqlRespond(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": payload}) +} + +func TestStatus_MissingCredentials(t *testing.T) { + t.Parallel() + ctx := &runtime.Context{Logger: nopLogger()} + h := execStatus.NewHandlerWithClient(ctx, wdcFor(t, "http://unused")) + err := h.Execute(context.Background(), execStatus.Inputs{ExecutionRef: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "credentials not available") +} + +func TestStatus_NotFound(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecution": map[string]any{"data": nil}, + }) + })) + t.Cleanup(srv.Close) + + h := execStatus.NewHandlerWithClient(rtCtxFor(t, srv.URL), wdcFor(t, srv.URL)) + err := h.Execute(context.Background(), execStatus.Inputs{ExecutionRef: "00000000-0000-0000-0000-000000000001"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestStatus_FailureShowsErrors(t *testing.T) { + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + finished := time.Date(2026, 5, 29, 14, 0, 17, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecution": map[string]any{ + "data": map[string]any{ + "uuid": "exec-uuid-1", + "workflowUUID": "wf-uuid-1", + "workflowName": "Price-Feed", + "status": "FAILURE", + "startedAt": started.Format(time.RFC3339), + "finishedAt": finished.Format(time.RFC3339), + "errors": []any{ + map[string]any{"error": "Invalid JSON: unexpected char", "count": 1}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + h := execStatus.NewHandlerWithClient(rtCtxFor(t, srv.URL), wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execStatus.Inputs{ + ExecutionRef: "05ace5cf-85ae-448b-9f42-270d42974d35", + OutputFormat: "json", + }) + require.NoError(t, err) + }) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "FAILURE", result["status"]) + errs, _ := result["errors"].([]any) + require.Len(t, errs, 1) + assert.Contains(t, errs[0].(map[string]any)["error"], "Invalid JSON") + assert.NotContains(t, out, "Debug further:") + assert.NotContains(t, out, "cre execution") +} + +func TestStatus_TableShowsDebugHints(t *testing.T) { + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + finished := time.Date(2026, 5, 29, 14, 0, 17, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecution": map[string]any{ + "data": map[string]any{ + "uuid": "exec-uuid-1", + "workflowUUID": "wf-uuid-1", + "workflowName": "Price-Feed", + "workflowId": "abc123onchain", + "status": "SUCCESS", + "startedAt": started.Format(time.RFC3339), + "finishedAt": finished.Format(time.RFC3339), + "errors": []any{}, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + h := execStatus.NewHandlerWithClient(rtCtxFor(t, srv.URL), wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execStatus.Inputs{ + ExecutionRef: "05ace5cf-85ae-448b-9f42-270d42974d35", + }) + require.NoError(t, err) + }) + + assert.Contains(t, out, "Debug further:") + assert.Contains(t, out, "cre execution events") + assert.Contains(t, out, "cre execution logs") +} diff --git a/cmd/generate-bindings/evm/evm.go b/cmd/generate-bindings/evm/evm.go index f0850b40..e6abb35f 100644 --- a/cmd/generate-bindings/evm/evm.go +++ b/cmd/generate-bindings/evm/evm.go @@ -343,7 +343,6 @@ func (h *handler) processAbiDirectory(inputs Inputs) error { indexContent += "// Code generated — DO NOT EDIT.\n" for _, name := range generatedContracts { indexContent += fmt.Sprintf("export * from './%s'\n", name) - indexContent += fmt.Sprintf("export * from './%s_mock'\n", name) } if err := os.WriteFile(indexPath, []byte(indexContent), 0o600); err != nil { return fmt.Errorf("failed to write index.ts: %w", err) diff --git a/cmd/generate-bindings/solana/README.md b/cmd/generate-bindings/solana/README.md new file mode 100644 index 00000000..72f2ba62 --- /dev/null +++ b/cmd/generate-bindings/solana/README.md @@ -0,0 +1,93 @@ +# CRE Solana Generated Bindings + +Generates CRE workflow bindings from Anchor IDL files, for **Go** and +**TypeScript**. + +The Go generator wraps a forked [anchor-go](https://github.com/gagliardetto/anchor-go) +(vendored under `./anchor-go`) and adds CRE write-report extensions. The +TypeScript generator (`tsbindgen.go`) consumes the same IDLs and emits bindings +on top of `@chainlink/cre-sdk` + `@solana/codecs`. + +## Usage + +```bash +# auto-detects go/typescript from project files +cre generate-bindings solana + +# explicit +cre generate-bindings solana --language typescript +cre generate-bindings solana --language go +``` + +Defaults: + +| | IDL input | Output | +|---|---|---| +| Go | `contracts/solana/src/idl/*.json` | `contracts/solana/src/generated//` (one package per program) | +| TypeScript | `contracts/solana/src/idl/*.json` | `contracts/solana/ts/generated/.ts` + `_mock.ts` + `index.ts` barrel | + +`--out` overrides the output directory for the selected language (rejected when +generating both languages at once — use `--language` to disambiguate). + +## What gets generated (CRE-reachable surface) + +Writes go through the keystone-forwarder: the on-chain entrypoint is always +`on_report`, and the payload is a bare Borsh-encoded struct. Accordingly, both +generators emit: + +- per-struct write methods: `writeReportFrom` (single) and + `writeReportFroms` (Borsh `Vec`, u32-LE count + concatenated elements), +- a generic `writeReport(payload)` and `writeReportFromBorshEncodedVec(payloads)`, +- pure account/event **decoders** (discriminator-checked) — there is no + read/simulate capability, so these only decode bytes obtained elsewhere, +- per-event **log-trigger bindings**: an `Filters` type, + `encodeSubkeys` (EQ comparers, OR across filter rows), and a typed + `logTriggerLog(filterName, filters, opts)` method whose output adapts + the raw log into decoded event data (Go: `bindings.DecodedLog[T]`, TS: + `SolanaDecodedLog`). `opts.cpi` targets Anchor `emit_cpi!` events. Only + top-level scalar fields with supported subkey encodings are auto-filterable; + nested structs, vecs, arrays, bool, u128, and i128 need a manual + `SubkeyConfig`. +- a program mock (`newMock`) that intercepts `writeReport` in the + test framework. + +Native Anchor instruction builders and account fetchers are **not** generated +for TypeScript: they are unreachable through the CRE capability. + +The wire format mirrors the Go bindings (`cre-sdk-go` solana `bindings` package): + +``` +ForwarderReport = [32-byte sha256(concat account pubkeys)][u32-LE payload len][payload] +report request: EncoderName="solana", SigningAlgo="ecdsa", HashingAlgo="keccak256" +``` + +## TypeScript type coverage (v1) + +| Anchor type | TypeScript | Codec | +|---|---|---| +| bool | boolean | `getBooleanCodec()` | +| u8…u32 / i8…i32 | number | `getU8Codec()`… | +| u64/u128 / i64/i128 | bigint | `getU64Codec()`… | +| f32 / f64 | number | `getF32Codec()` / `getF64Codec()` | +| string | string | `addCodecSizePrefix(getUtf8Codec(), getU32Codec())` | +| bytes | Uint8Array | `addCodecSizePrefix(getBytesCodec(), getU32Codec())` | +| pubkey | Address | `getAddressCodec()` | +| vec\ | T[] | `getArrayCodec(inner, { size: getU32Codec() })` | +| array\ | T[] | `getArrayCodec(inner, { size: N })` | +| option\ | T \| null | `getNullableCodec(inner)` | +| defined struct | type ref | generated codec const | +| enum (scalar) | TS enum | `getEnumCodec(Enum)` (u8 tag) | + +Unsupported types **fail loudly** at generation time (never silently +mis-encode): `u256`/`i256`, `COption`, data-carrying enums, tuple structs, +generics, cyclic type references. + +## Tests + +Golden source-compare tests live in `tsbindgen_test.go` against +`testdata/data_storage_ts/` and `testdata/feature_matrix_ts/`. Regenerate +goldens (Go + TS) with: + +```bash +go generate ./cmd/generate-bindings/solana +``` diff --git a/cmd/generate-bindings/solana/bindgen.go b/cmd/generate-bindings/solana/bindgen.go index f9f5b98e..bf39d48a 100644 --- a/cmd/generate-bindings/solana/bindgen.go +++ b/cmd/generate-bindings/solana/bindgen.go @@ -49,9 +49,11 @@ func GenerateBindings( return fmt.Errorf("invalid IDL: %w", err) } if parsedIdl.Address == nil || parsedIdl.Address.IsZero() { - return fmt.Errorf("address is empty in idl file: %s", pathToIdl) + slog.Warn("IDL has no address field; program_id.go will not be generated — pass the program ID at runtime", + "path", pathToIdl) + } else { + slog.Info("Using IDL address as program ID", "address", parsedIdl.Address.String()) } - slog.Info("Using IDL address as program ID", "address", parsedIdl.Address.String()) parsedIdl.Metadata.Name = bin.ToSnakeForSighash(parsedIdl.Metadata.Name) // check that the name is not a reserved keyword: @@ -85,11 +87,14 @@ func GenerateBindings( ProgramId: parsedIdl.Address, } + programIDStr := "" + if parsedIdl.Address != nil && !parsedIdl.Address.IsZero() { + programIDStr = parsedIdl.Address.String() + } slog.Info("Parsed IDL successfully", "version", parsedIdl.Metadata.Version, "name", parsedIdl.Metadata.Name, - "address", parsedIdl.Address, - "programId", parsedIdl.Address.String(), + "programId", programIDStr, "instructionsCount", len(parsedIdl.Instructions), "accountsCount", len(parsedIdl.Accounts), "eventsCount", len(parsedIdl.Events), diff --git a/cmd/generate-bindings/solana/gen_test.go b/cmd/generate-bindings/solana/gen_test.go index 1a97f327..1988a727 100644 --- a/cmd/generate-bindings/solana/gen_test.go +++ b/cmd/generate-bindings/solana/gen_test.go @@ -1,8 +1,13 @@ package solana_test import ( + "os" + "path/filepath" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/smartcontractkit/cre-cli/cmd/generate-bindings/solana" ) @@ -15,3 +20,29 @@ func TestGenerateBindings(t *testing.T) { t.Fatal(err) } } + +// TestGenerateBindings_MissingAddress verifies that an IDL without an address +// field succeeds (previously it returned an error). program_id.go must not be +// generated since there is no address to embed. +func TestGenerateBindings_MissingAddress(t *testing.T) { + idl := `{ + "metadata": {"name": "no_addr", "version": "0.1.0", "spec": "0.1.0"}, + "instructions": [ + {"name": "on_report", "discriminator": [214,173,18,221,173,148,151,208], "accounts": [], "args": []} + ], + "accounts": [], + "events": [], + "errors": [], + "types": [] +}` + idlPath := filepath.Join(t.TempDir(), "no_addr.json") + require.NoError(t, os.WriteFile(idlPath, []byte(idl), 0o600)) + + outDir := t.TempDir() + err := solana.GenerateBindings(idlPath, "no_addr", outDir) + require.NoError(t, err) + + // program_id.go must not be generated when there is no address in the IDL. + _, statErr := os.Stat(filepath.Join(outDir, "program_id.go")) + assert.True(t, os.IsNotExist(statErr), "program_id.go should not be generated when IDL has no address") +} diff --git a/cmd/generate-bindings/solana/mockcontract.ts.tpl b/cmd/generate-bindings/solana/mockcontract.ts.tpl new file mode 100644 index 00000000..e3669a12 --- /dev/null +++ b/cmd/generate-bindings/solana/mockcontract.ts.tpl @@ -0,0 +1,19 @@ +// Code generated — DO NOT EDIT. +import { addSolanaContractMock, type SolanaContractMock, type SolanaMock } from '@chainlink/cre-sdk/test' + +import { {{.ProgramIDConst}} } from './{{.ClassName}}' + +export type {{.MockName}} = SolanaContractMock + +/** + * Registers a {{.ClassName}} program mock on a SolanaMock instance. + * The Solana CRE capability is write-only, so the mock routes writeReport + * calls targeting this program's ID; set the returned mock's writeReport + * property to define the reply. + */ +export function new{{.MockName}}( + solanaMock: SolanaMock, + programId: string | Uint8Array = {{.ProgramIDConst}}, +): {{.MockName}} { + return addSolanaContractMock(solanaMock, { programId }) +} diff --git a/cmd/generate-bindings/solana/solana.go b/cmd/generate-bindings/solana/solana.go index 32ee92b4..10176ac2 100644 --- a/cmd/generate-bindings/solana/solana.go +++ b/cmd/generate-bindings/solana/solana.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -18,9 +19,11 @@ import ( type Inputs struct { ProjectRoot string `validate:"required,dir" cli:"--project-root"` - Language string `validate:"required,oneof=go" cli:"--language"` + GoLang bool + TypeScript bool IdlPath string `validate:"required,path_read" cli:"--idl"` - OutPath string `validate:"required" cli:"--out"` + GoOutPath string // contracts/solana/src/generated — set when GoLang is true + TSOutPath string // contracts/solana/ts/generated — set when TypeScript is true } func New(runtimeContext *runtime.Context) *cobra.Command { @@ -28,10 +31,14 @@ func New(runtimeContext *runtime.Context) *cobra.Command { Use: "solana", Short: "Generate bindings from contract IDL", Long: `This command generates bindings from contract IDL files. -Supports Solana chain family and Go language. -Each contract gets its own package subdirectory to avoid naming conflicts. -For example, data_storage.json generates bindings in generated/data_storage/ package.`, - Example: " cre generate-bindings-solana", +Supports Solana chain family with Go and TypeScript languages. +The target language is auto-detected from project files, or can be +specified explicitly with --language. +For Go, each contract gets its own package subdirectory to avoid naming +conflicts: data_storage.json generates bindings in generated/data_storage/. +For TypeScript, each contract generates a flat .ts + _mock.ts +pair plus an index.ts barrel.`, + Example: " cre generate-bindings solana", RunE: func(cmd *cobra.Command, args []string) error { handler := newHandler(runtimeContext) inputs, err := handler.ResolveInputs(runtimeContext.Viper) @@ -46,9 +53,9 @@ For example, data_storage.json generates bindings in generated/data_storage/ pac } generateBindingsCmd.Flags().StringP("project-root", "p", "", "Path to project root directory (defaults to current directory)") - generateBindingsCmd.Flags().StringP("language", "l", "go", "Target language (go)") + generateBindingsCmd.Flags().StringP("language", "l", "", "Target language: go, typescript (auto-detected from project files when omitted)") generateBindingsCmd.Flags().StringP("idl", "i", "", "Path to IDL directory (defaults to contracts/solana/src/idl/)") - generateBindingsCmd.Flags().StringP("out", "o", "", "Path to output directory (defaults to contracts/solana/src/generated/)") + generateBindingsCmd.Flags().StringP("out", "o", "", "Path to output directory (defaults to contracts/solana/src/generated/ for Go, contracts/solana/ts/generated/ for TypeScript)") return generateBindingsCmd } @@ -63,6 +70,34 @@ func newHandler(ctx *runtime.Context) *handler { } } +// detectLanguages mirrors the EVM generate-bindings auto-detection: a project +// containing .go files targets Go, one containing .ts files targets TypeScript. +func detectLanguages(projectRoot string) (goLang, typescript bool) { + _ = filepath.WalkDir(projectRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if d.Name() == "node_modules" || d.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + base := filepath.Base(path) + if strings.HasSuffix(base, ".go") { + goLang = true + } + if strings.HasSuffix(base, ".ts") && !strings.HasSuffix(base, ".d.ts") { + typescript = true + } + if goLang && typescript { + return filepath.SkipAll + } + return nil + }) + return goLang, typescript +} + func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { // Get current working directory as default project root currentDir, err := os.Getwd() @@ -81,8 +116,22 @@ func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { return Inputs{}, fmt.Errorf("contracts folder not found in project root: %s", contractsPath) } - // Language defaults are handled by StringP - language := v.GetString("language") + // Resolve languages: --language flag takes precedence, else auto-detect + var goLang, typescript bool + langFlag := strings.ToLower(strings.TrimSpace(v.GetString("language"))) + switch langFlag { + case "": + goLang, typescript = detectLanguages(projectRoot) + if !goLang && !typescript { + return Inputs{}, fmt.Errorf("no target language detected (use --language go or --language typescript, or ensure project contains .go or .ts files)") + } + case constants.WorkflowLanguageGolang: + goLang = true + case constants.WorkflowLanguageTypeScript: + typescript = true + default: + return Inputs{}, fmt.Errorf("unsupported language %q (supported: go, typescript)", langFlag) + } // Resolve IDL path with fallback to contracts/solana/src/idl/ idlPath := v.GetString("idl") @@ -90,17 +139,35 @@ func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { idlPath = filepath.Join(projectRoot, "contracts", "solana", "src", "idl") } - // Resolve output path with fallback to contracts/solana/src/generated/ - outPath := v.GetString("out") - if outPath == "" { - outPath = filepath.Join(projectRoot, "contracts", "solana", "src", "generated") + // Separate output paths: Go uses src/, TS uses ts/ (typescript convention). + // --out overrides the default for the selected language; with both + // languages selected it would be ambiguous, so it is rejected. + outFlag := v.GetString("out") + if outFlag != "" && goLang && typescript { + return Inputs{}, fmt.Errorf("--out is ambiguous when generating for both go and typescript; use --language to select one") + } + + var goOutPath, tsOutPath string + if goLang { + goOutPath = outFlag + if goOutPath == "" { + goOutPath = filepath.Join(projectRoot, "contracts", "solana", "src", "generated") + } + } + if typescript { + tsOutPath = outFlag + if tsOutPath == "" { + tsOutPath = filepath.Join(projectRoot, "contracts", "solana", "ts", "generated") + } } return Inputs{ ProjectRoot: projectRoot, - Language: language, + GoLang: goLang, + TypeScript: typescript, IdlPath: idlPath, - OutPath: outPath, + GoOutPath: goOutPath, + TSOutPath: tsOutPath, }, nil } @@ -136,6 +203,48 @@ func (h *handler) ValidateInputs(inputs Inputs) error { return nil } +// contractNameFromIdlFile returns the contract name by stripping the .json +// extension from the base filename. +func contractNameFromIdlFile(path string) string { + return strings.TrimSuffix(filepath.Base(path), ".json") +} + +func (h *handler) generateForIdl(inputs Inputs, idlFile string) (className string, err error) { + contractName := contractNameFromIdlFile(idlFile) + + if inputs.TypeScript { + ui.Dim(fmt.Sprintf("Processing IDL file: %s, contract: %s, output: %s\n", idlFile, contractName, inputs.TSOutPath)) + className, err = GenerateBindingsTS( + idlFile, + contractName, + inputs.TSOutPath, + ) + if err != nil { + return "", fmt.Errorf("failed to generate TypeScript bindings for %s: %w", idlFile, err) + } + } + + if inputs.GoLang { + // Create per-contract output directory + contractOutDir := filepath.Join(inputs.GoOutPath, contractName) + if err := os.MkdirAll(contractOutDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create contract output directory %s: %w", contractOutDir, err) + } + + ui.Dim(fmt.Sprintf("Processing IDL file: %s, contract: %s, output: %s\n", idlFile, contractName, contractOutDir)) + + if err := GenerateBindings( + idlFile, + contractName, + contractOutDir, + ); err != nil { + return "", fmt.Errorf("failed to generate bindings for %s: %w", idlFile, err) + } + } + + return className, nil +} + func (h *handler) processIdlDirectory(inputs Inputs) error { // Read all .json files in the directory files, err := filepath.Glob(filepath.Join(inputs.IdlPath, "*.json")) @@ -147,70 +256,69 @@ func (h *handler) processIdlDirectory(inputs Inputs) error { return fmt.Errorf("no .json files found in directory: %s", inputs.IdlPath) } - // Process each IDL file + var generatedTSClasses []string for _, idlFile := range files { - // Extract contract name from filename (remove .json extension) - contractName := filepath.Base(idlFile) - contractName = contractName[:len(contractName)-5] // Remove .json extension - - // Create per-contract output directory - contractOutDir := filepath.Join(inputs.OutPath, contractName) - if err := os.MkdirAll(contractOutDir, 0755); err != nil { - return fmt.Errorf("failed to create contract output directory %s: %w", contractOutDir, err) - } - - // Create output file path in contract-specific directory - outputFile := filepath.Join(contractOutDir, contractName+".go") - - ui.Dim(fmt.Sprintf("Processing IDL file: %s, contract: %s, output: %s\n", idlFile, contractName, outputFile)) - - err = GenerateBindings( - idlFile, - contractName, - contractOutDir, - ) + className, err := h.generateForIdl(inputs, idlFile) if err != nil { - return fmt.Errorf("failed to generate bindings for %s: %w", idlFile, err) + return err + } + if className != "" { + generatedTSClasses = append(generatedTSClasses, className) } } - return nil + return h.writeTSBarrel(inputs, generatedTSClasses) } func (h *handler) processSingleIdl(inputs Inputs) error { - // Extract contract name from IDL file path - contractName := filepath.Base(inputs.IdlPath) - if filepath.Ext(contractName) == ".json" { - contractName = contractName[:len(contractName)-5] // Remove .json extension + className, err := h.generateForIdl(inputs, inputs.IdlPath) + if err != nil { + return err } - - // Create per-contract output directory - contractOutDir := filepath.Join(inputs.OutPath, contractName) - if err := os.MkdirAll(contractOutDir, 0755); err != nil { - return fmt.Errorf("failed to create contract output directory %s: %w", contractOutDir, err) + var classNames []string + if className != "" { + classNames = []string{className} } + return h.writeTSBarrel(inputs, classNames) +} - ui.Dim(fmt.Sprintf("Processing single IDL file: %s, contract: %s, output: %s\n", inputs.IdlPath, contractName, contractOutDir)) - - return GenerateBindings( - inputs.IdlPath, - contractName, - contractOutDir, - ) +// writeTSBarrel generates the index.ts barrel re-exporting every generated +// binding and mock, matching the EVM TypeScript output convention. +func (h *handler) writeTSBarrel(inputs Inputs, classNames []string) error { + if !inputs.TypeScript || len(classNames) == 0 { + return nil + } + indexPath := filepath.Join(inputs.TSOutPath, "index.ts") + var sb strings.Builder + sb.WriteString("// Code generated — DO NOT EDIT.\n") + for _, name := range classNames { + fmt.Fprintf(&sb, "export * from './%s'\n", name) + } + if err := os.WriteFile(indexPath, []byte(sb.String()), 0o600); err != nil { + return fmt.Errorf("failed to write index.ts: %w", err) + } + return nil } func (h *handler) Execute(inputs Inputs) error { - // Validate language - switch inputs.Language { - case "go": - // Language supported, continue - default: - return fmt.Errorf("unsupported language: %s", inputs.Language) + var langs []string + if inputs.GoLang { + langs = append(langs, "go") + } + if inputs.TypeScript { + langs = append(langs, "typescript") } + ui.Dim(fmt.Sprintf("Project: %s, Chain: solana, Languages: %v", inputs.ProjectRoot, langs)) - // Create output directory if it doesn't exist - if err := os.MkdirAll(inputs.OutPath, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) + if inputs.GoLang { + if err := os.MkdirAll(inputs.GoOutPath, 0o755); err != nil { + return fmt.Errorf("failed to create Go output directory: %w", err) + } + } + if inputs.TypeScript { + if err := os.MkdirAll(inputs.TSOutPath, 0o755); err != nil { + return fmt.Errorf("failed to create TypeScript output directory: %w", err) + } } // Check if IDL path is a directory or file @@ -229,19 +337,22 @@ func (h *handler) Execute(inputs Inputs) error { } } - err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go@"+constants.SdkVersion) - if err != nil { - return err - } - err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana@"+constants.SolanaCapabilitiesVersion) - if err != nil { - return err - } - err = runCommand(inputs.ProjectRoot, "go", "mod", "tidy") - if err != nil { - return err + if inputs.GoLang { + err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go@"+constants.SdkVersion) + if err != nil { + return err + } + err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana@"+constants.SolanaCapabilitiesVersion) + if err != nil { + return err + } + err = runCommand(inputs.ProjectRoot, "go", "mod", "tidy") + if err != nil { + return err + } } + ui.Success("Bindings generated successfully") return nil } diff --git a/cmd/generate-bindings/solana/solana_test.go b/cmd/generate-bindings/solana/solana_test.go index e3eebfa9..ca8bf09d 100644 --- a/cmd/generate-bindings/solana/solana_test.go +++ b/cmd/generate-bindings/solana/solana_test.go @@ -53,12 +53,13 @@ func TestResolveSolanaInputs_DefaultFallbacks(t *testing.T) { expectedRoot, _ := filepath.EvalSymlinks(tempDir) actualRoot, _ := filepath.EvalSymlinks(inputs.ProjectRoot) assert.Equal(t, expectedRoot, actualRoot) - assert.Equal(t, "go", inputs.Language) + assert.True(t, inputs.GoLang) + assert.False(t, inputs.TypeScript) expectedIdl, _ := filepath.EvalSymlinks(filepath.Join(tempDir, "contracts", "solana", "src", "idl")) actualIdl, _ := filepath.EvalSymlinks(inputs.IdlPath) assert.Equal(t, expectedIdl, actualIdl) expectedOut, _ := filepath.EvalSymlinks(filepath.Join(tempDir, "contracts", "solana", "src", "generated")) - actualOut, _ := filepath.EvalSymlinks(inputs.OutPath) + actualOut, _ := filepath.EvalSymlinks(inputs.GoOutPath) assert.Equal(t, expectedOut, actualOut) } @@ -94,7 +95,7 @@ func TestResolveSolanaInputs_CustomOutPath(t *testing.T) { inputs, err := handler.ResolveInputs(v) require.NoError(t, err) - assert.Equal(t, customOut, inputs.OutPath) + assert.Equal(t, customOut, inputs.GoOutPath) } func TestProcessSolanaSingleIdl(t *testing.T) { @@ -144,9 +145,9 @@ func TestProcessSolanaSingleIdl(t *testing.T) { inputs := Inputs{ ProjectRoot: tempDir, - Language: "go", + GoLang: true, IdlPath: idlFile, - OutPath: outDir, + GoOutPath: outDir, } runtimeCtx := &runtime.Context{} @@ -233,9 +234,9 @@ func TestProcessSolanaIdlDirectory(t *testing.T) { inputs := Inputs{ ProjectRoot: tempDir, - Language: "go", + GoLang: true, IdlPath: idlDir, - OutPath: outDir, + GoOutPath: outDir, } runtimeCtx := &runtime.Context{} @@ -271,9 +272,9 @@ func TestProcessSolanaIdlDirectory_NoIdlFiles(t *testing.T) { inputs := Inputs{ ProjectRoot: tempDir, - Language: "go", + GoLang: true, IdlPath: idlDir, - OutPath: outDir, + GoOutPath: outDir, } runtimeCtx := &runtime.Context{} diff --git a/cmd/generate-bindings/solana/sourcecre.ts.tpl b/cmd/generate-bindings/solana/sourcecre.ts.tpl new file mode 100644 index 00000000..43add2a9 --- /dev/null +++ b/cmd/generate-bindings/solana/sourcecre.ts.tpl @@ -0,0 +1,303 @@ +// Code generated — DO NOT EDIT. +{{- if .CodecImports}} +import { +{{- range .CodecImports}} + {{.}}, +{{- end}} +} from '@solana/codecs' +{{- end}} +{{- if .UsesAddress}} +import { getAddressCodec, type Address } from '@solana/addresses' +{{- end}} +import { +{{- if .Events}} + adaptTrigger, + anchorCPILogTriggerConfig, + bytesToBase64, +{{- end}} + bytesToHex, + calculateAccountsHash, + encodeBorshVecU32, + encodeForwarderReport, + prepareSolanaReportRequest, +{{- if .UsesFloatSubkey}} + prepareSubkeyFloatValue, +{{- end}} +{{- if .UsesSubkeyValue}} + prepareSubkeyValue, +{{- end}} + type Runtime, + type SolanaAccountMeta, + SolanaClient, + solanaAccountMetasToJson, + solanaAddressToBytes, + type SolanaComputeConfig, +{{- if .Events}} + type SolanaDecodedLog, + type SolanaFilterLogTriggerRequestJson, + type SolanaLog, + type SolanaLogTriggerOptions, + type SolanaSubkeyConfigJson, + type SolanaValueComparatorJson, + type Trigger, +{{- end}} +} from '@chainlink/cre-sdk' + +export const {{.ProgramIDConst}} = '{{.ProgramID}}' + +export const {{.IdlConst}} = {{.IdlJSON}} as const +{{- if .Events}} + +// Base64 of the compact IDL JSON, passed to log triggers as contractIdlJson. +const {{.IdlB64Const}} = '{{.IdlB64}}' +{{- end}} +{{- if or .Accounts .Events}} + +const DISCRIMINATOR_SIZE = 8 + +const expectDiscriminator = (label: string, expected: Uint8Array, data: Uint8Array): Uint8Array => { + if (data.length < DISCRIMINATOR_SIZE) { + throw new Error(`${label}: data too short for discriminator (${data.length} bytes)`) + } + for (let i = 0; i < DISCRIMINATOR_SIZE; i++) { + if (data[i] !== expected[i]) { + throw new Error(`${label}: discriminator mismatch`) + } + } + return data.subarray(DISCRIMINATOR_SIZE) +} +{{- end}} +{{range .Types}} +{{- if .IsEnum}} +export enum {{.Name}} { +{{- range $i, $v := .Variants}} + {{$v.Name}} = {{$i}}, +{{- end}} +} + +export const {{.CodecConst}} = getEnumCodec({{.Name}}) +{{- else}} +{{- if .Fields}} +export type {{.Name}} = { +{{- range .Fields}} + {{.Name}}: {{.TSType}} +{{- end}} +} + +export const {{.CodecConst}} = getStructCodec([ +{{- range .Fields}} + ['{{.Name}}', {{.CodecExpr}}], +{{- end}} +]) +{{- else}} +export type {{.Name}} = Record + +export const {{.CodecConst}} = getStructCodec([]) +{{- end}} +{{- end}} +{{end}} +{{- range .Accounts}} +export const {{.ConstName}} = new Uint8Array([{{.Discriminator}}]) + +/** + * Decodes raw {{.Name}} account data (with its 8-byte discriminator) into {{.TypeName}}. + * Pure helper — there is no read capability; obtain the account bytes elsewhere. + */ +export const decode{{.Name}}Account = (data: Uint8Array): {{.TypeName}} => + {{.CodecConst}}.decode(expectDiscriminator('account {{.Name}}', {{.ConstName}}, data)) as {{.TypeName}} +{{end}} +{{- range .Events}} +export const {{.ConstName}} = new Uint8Array([{{.Discriminator}}]) + +/** + * Decodes raw {{.Name}} event data (with its 8-byte discriminator) into {{.TypeName}}. + */ +export const decode{{.Name}}Event = (data: Uint8Array): {{.TypeName}} => + {{.CodecConst}}.decode(expectDiscriminator('event {{.Name}}', {{.ConstName}}, data)) as {{.TypeName}} +{{end}} +{{- if .Accounts}} +export const parseAnyAccount = (data: Uint8Array): {{range $i, $a := .Accounts}}{{if $i}} | {{end}}{{$a.TypeName}}{{end}} => { + const disc = data.subarray(0, DISCRIMINATOR_SIZE) + const matches = (expected: Uint8Array) => expected.every((b, i) => disc[i] === b) +{{- range .Accounts}} + if (matches({{.ConstName}})) return decode{{.Name}}Account(data) +{{- end}} + throw new Error(`unknown account discriminator: [${Array.from(disc).join(', ')}]`) +} +{{- end}} +{{- if .Events}} + +export const parseAnyEvent = (data: Uint8Array): {{range $i, $e := .Events}}{{if $i}} | {{end}}{{$e.TypeName}}{{end}} => { + const disc = data.subarray(0, DISCRIMINATOR_SIZE) + const matches = (expected: Uint8Array) => expected.every((b, i) => disc[i] === b) +{{- range .Events}} + if (matches({{.ConstName}})) return decode{{.Name}}Event(data) +{{- end}} + throw new Error(`unknown event discriminator: [${Array.from(disc).join(', ')}]`) +} +{{- end}} +{{- range .Triggers}} + +/** + * Optional filter values for {{.Name}} log triggers. Set a field to filter on + * that value (OR across filter rows). Leave unset for wildcard. Only top-level + * scalar fields with supported subkey encodings are auto-filterable — nested + * structs, vecs, arrays, bool, u128, and i128 need a manual SubkeyConfig. + */ +{{- if .FilterFields}} +export type {{.Name}}Filters = { +{{- range .FilterFields}} + {{.Name}}?: {{.TSType}} | null +{{- end}} +} + +export const encode{{.Name}}Subkeys = (filters: {{.Name}}Filters[]): SolanaSubkeyConfigJson[] => { +{{- range .FilterFields}} + const {{.Name}}Comparers: SolanaValueComparatorJson[] = [] +{{- end}} + for (const f of filters) { +{{- range .FilterFields}} + if (f.{{.Name}} != null) { + {{.Name}}Comparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64({{.EncodeExpr}}), + }) + } +{{- end}} + } + const subkeys: SolanaSubkeyConfigJson[] = [] +{{- range .FilterFields}} + if ({{.Name}}Comparers.length > 0) { + subkeys.push({ path: ['{{.PathName}}'], comparers: {{.Name}}Comparers }) + } +{{- end}} + return subkeys +} +{{- else}} +export type {{.Name}}Filters = Record + +export const encode{{.Name}}Subkeys = (_filters: {{.Name}}Filters[]): SolanaSubkeyConfigJson[] => [] +{{- end}} +{{- end}} +{{- if or .Accounts .Events}} +{{end}} +export class {{.ClassName}} { + readonly programId: Uint8Array + + // The program ID is baked into the IDL, so it defaults to the generated + // const — unlike EVM bindings where the address is a runtime value. + constructor( + private readonly client: SolanaClient, + programId: string | Uint8Array = {{.ProgramIDConst}}, + ) { + this.programId = typeof programId === 'string' ? solanaAddressToBytes(programId) : programId + } + + /** + * Publishes a pre-encoded Borsh payload through the CRE signer to this + * program's on_report entrypoint via the keystone-forwarder. + * + * remainingAccounts must follow the keystone-forwarder account layout: + * - Index 0: forwarderState – the forwarder program's state account. + * - Index 1: forwarderAuthority – PDA derived from seeds + * ["forwarder", forwarderState, receiverProgram] under the forwarder program ID. + * - Index 2+: receiver-specific accounts required by the target program. + * + * The full account list is hashed (via calculateAccountsHash) into the report. + * The on-chain forwarder strips indices 0 and 1 before CPI-ing into the + * receiver, so they must be present and correctly ordered. + */ + writeReport( + runtime: Runtime, + payload: Uint8Array, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + const report = runtime + .report( + prepareSolanaReportRequest( + encodeForwarderReport({ + accountHash: calculateAccountsHash(remainingAccounts), + payload, + }), + ), + ) + .result() + + return this.client + .writeReport(runtime, { + remainingAccounts: solanaAccountMetasToJson(remainingAccounts), + receiver: bytesToHex(this.programId), + computeConfig, + report, + }) + .result() + } + + /** + * Publishes a Borsh Vec of pre-encoded element payloads (mirrors Go's + * WriteReportFromBorshEncodedVec). Each element must already be fully + * serialized for one Vec item on the wire. + */ + writeReportFromBorshEncodedVec( + runtime: Runtime, + elementPayloads: Uint8Array[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, encodeBorshVecU32(elementPayloads), remainingAccounts, computeConfig) + } +{{- range .StructTypes}} + + writeReportFrom{{.Name}}( + runtime: Runtime, + input: {{.Name}}, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array({{.CodecConst}}.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFrom{{.Name}}s( + runtime: Runtime, + inputs: {{.Name}}[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array({{.CodecConst}}.encode(input))), + remainingAccounts, + computeConfig, + ) + } +{{- end}} +{{- range .Triggers}} + + /** + * Registers a typed log trigger for {{.Name}} events. The trigger + * output is adapted to the decoded {{.Name}} data alongside the raw log. + * Pass opts.cpi for events emitted via Anchor's emit_cpi!. + */ + logTrigger{{.Name}}Log( + filterName: string, + filters: {{.Name}}Filters[] = [], + opts?: SolanaLogTriggerOptions, + ): Trigger> { + const config: SolanaFilterLogTriggerRequestJson = { + name: filterName, + address: bytesToBase64(this.programId), + eventName: '{{.EventName}}', + contractIdlJson: {{$.IdlB64Const}}, + subkeys: encode{{.Name}}Subkeys(filters), + } + if (opts?.cpi) { + config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId) + } + return adaptTrigger(this.client.logTrigger(config), (log) => ({ + log, + data: decode{{.Name}}Event(log.data), + })) + } +{{- end}} +} diff --git a/cmd/generate-bindings/solana/testdata/contracts/idl/feature_matrix.json b/cmd/generate-bindings/solana/testdata/contracts/idl/feature_matrix.json new file mode 100644 index 00000000..5b9abdb7 --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/contracts/idl/feature_matrix.json @@ -0,0 +1,78 @@ +{ + "address": "ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL", + "metadata": { + "name": "feature_matrix", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Fixture exercising the v1 TypeScript type-coverage matrix" + }, + "instructions": [ + { + "name": "on_report", + "discriminator": [214, 173, 18, 221, 173, 148, 151, 208], + "accounts": [], + "args": [ + {"name": "_metadata", "type": "bytes"}, + {"name": "payload", "type": "bytes"} + ] + } + ], + "accounts": [], + "events": [], + "errors": [], + "types": [ + { + "name": "Color", + "type": { + "kind": "enum", + "variants": [ + {"name": "Red"}, + {"name": "Green"}, + {"name": "Blue"} + ] + } + }, + { + "name": "Inner", + "type": { + "kind": "struct", + "fields": [ + {"name": "id", "type": "u32"}, + {"name": "label", "type": "string"} + ] + } + }, + { + "name": "Everything", + "type": { + "kind": "struct", + "fields": [ + {"name": "flag", "type": "bool"}, + {"name": "tiny", "type": "u8"}, + {"name": "tiny_signed", "type": "i8"}, + {"name": "small", "type": "u16"}, + {"name": "small_signed", "type": "i16"}, + {"name": "medium", "type": "u32"}, + {"name": "medium_signed", "type": "i32"}, + {"name": "big", "type": "u64"}, + {"name": "big_signed", "type": "i64"}, + {"name": "huge", "type": "u128"}, + {"name": "huge_signed", "type": "i128"}, + {"name": "ratio", "type": "f32"}, + {"name": "precise_ratio", "type": "f64"}, + {"name": "title", "type": "string"}, + {"name": "blob", "type": "bytes"}, + {"name": "owner", "type": "pubkey"}, + {"name": "scores", "type": {"vec": "u64"}}, + {"name": "fixed_window", "type": {"array": ["u8", 32]}}, + {"name": "maybe_note", "type": {"option": "string"}}, + {"name": "nested", "type": {"defined": {"name": "Inner"}}}, + {"name": "maybe_nested", "type": {"option": {"defined": {"name": "Inner"}}}}, + {"name": "favorite_color", "type": {"defined": {"name": "Color"}}}, + {"name": "color_history", "type": {"vec": {"defined": {"name": "Color"}}}}, + {"name": "default", "type": "bool"} + ] + } + } + ] +} diff --git a/cmd/generate-bindings/solana/testdata/data_storage/constructor.go b/cmd/generate-bindings/solana/testdata/data_storage/constructor.go index 33ac49e8..2fcc51fb 100644 --- a/cmd/generate-bindings/solana/testdata/data_storage/constructor.go +++ b/cmd/generate-bindings/solana/testdata/data_storage/constructor.go @@ -6,7 +6,6 @@ package data_storage import ( "bytes" "encoding/binary" - sdk "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" solana "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana" bindings "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana/bindings" diff --git a/cmd/generate-bindings/solana/testdata/data_storage/triggers.go b/cmd/generate-bindings/solana/testdata/data_storage/triggers.go index 1f67fe07..a2acde60 100644 --- a/cmd/generate-bindings/solana/testdata/data_storage/triggers.go +++ b/cmd/generate-bindings/solana/testdata/data_storage/triggers.go @@ -5,7 +5,6 @@ package data_storage import ( "fmt" - solanago "github.com/gagliardetto/solana-go" solana "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana" bindings "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana/bindings" diff --git a/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts new file mode 100644 index 00000000..e6338638 --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts @@ -0,0 +1,548 @@ +// Code generated — DO NOT EDIT. +import { + addCodecSizePrefix, + getArrayCodec, + getBytesCodec, + getStructCodec, + getU32Codec, + getU64Codec, + getUtf8Codec, +} from '@solana/codecs' +import { getAddressCodec, type Address } from '@solana/addresses' +import { + adaptTrigger, + anchorCPILogTriggerConfig, + bytesToBase64, + bytesToHex, + calculateAccountsHash, + encodeBorshVecU32, + encodeForwarderReport, + prepareSolanaReportRequest, + prepareSubkeyValue, + type Runtime, + type SolanaAccountMeta, + SolanaClient, + solanaAccountMetasToJson, + solanaAddressToBytes, + type SolanaComputeConfig, + type SolanaDecodedLog, + type SolanaFilterLogTriggerRequestJson, + type SolanaLog, + type SolanaLogTriggerOptions, + type SolanaSubkeyConfigJson, + type SolanaValueComparatorJson, + type Trigger, +} from '@chainlink/cre-sdk' + +export const DATA_STORAGE_PROGRAM_ID = 'ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL' + +export const DATA_STORAGE_IDL = {"address":"ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL","metadata":{"name":"data_storage","version":"0.1.0","spec":"0.1.0","description":"Created with Anchor"},"instructions":[{"name":"get_multiple_reserves","discriminator":[104,122,140,104,175,151,70,42],"accounts":[],"args":[],"returns":{"vec":{"defined":{"name":"UpdateReserves"}}}},{"name":"get_reserves","discriminator":[121,140,237,84,218,105,48,17],"accounts":[],"args":[],"returns":{"defined":{"name":"UpdateReserves"}}},{"name":"get_tuple_reserves","discriminator":[189,83,186,20,127,80,109,49],"accounts":[],"args":[]},{"name":"initialize_data_account","discriminator":[9,64,78,49,71,193,15,250],"accounts":[{"name":"data_account","writable":true,"pda":{"seeds":[{"kind":"const","value":[100,97,116,97,95,97,99,99,111,117,110,116]},{"kind":"account","path":"user"}]}},{"name":"user","writable":true,"signer":true},{"name":"system_program","address":"11111111111111111111111111111111"}],"args":[{"name":"input","type":{"defined":{"name":"UserData"}}}]},{"name":"log_access","discriminator":[196,55,194,24,5,224,161,204],"accounts":[{"name":"user","signer":true}],"args":[{"name":"message","type":"string"}]},{"name":"on_report","discriminator":[214,173,18,221,173,148,151,208],"accounts":[{"name":"user","writable":true,"signer":true},{"name":"data_account","writable":true,"pda":{"seeds":[{"kind":"const","value":[100,97,116,97,95,97,99,99,111,117,110,116]},{"kind":"account","path":"user"}]}},{"name":"system_program","address":"11111111111111111111111111111111"}],"args":[{"name":"_metadata","type":"bytes"},{"name":"payload","type":"bytes"}]},{"name":"update_key_value_data","discriminator":[67,137,144,35,210,126,254,79],"accounts":[{"name":"user","writable":true,"signer":true},{"name":"data_account","writable":true,"pda":{"seeds":[{"kind":"const","value":[100,97,116,97,95,97,99,99,111,117,110,116]},{"kind":"account","path":"user"}]}}],"args":[{"name":"key","type":"string"},{"name":"value","type":"string"}]},{"name":"update_user_data","discriminator":[11,13,114,150,194,224,192,78],"accounts":[{"name":"user","writable":true,"signer":true},{"name":"data_account","writable":true,"pda":{"seeds":[{"kind":"const","value":[100,97,116,97,95,97,99,99,111,117,110,116]},{"kind":"account","path":"user"}]}}],"args":[{"name":"input","type":{"defined":{"name":"UserData"}}}]}],"accounts":[{"name":"DataAccount","discriminator":[85,240,182,158,76,7,18,233]}],"events":[{"name":"AccessLogged","discriminator":[243,53,225,71,64,120,109,25]},{"name":"DynamicEvent","discriminator":[236,145,224,161,9,222,218,237]},{"name":"NoFields","discriminator":[160,156,94,85,77,122,98,240]}],"errors":[{"code":6000,"name":"DataNotFound","msg":"data not found"}],"types":[{"name":"AccessLogged","type":{"kind":"struct","fields":[{"name":"caller","type":"pubkey"},{"name":"message","type":"string"}]}},{"name":"DataAccount","type":{"kind":"struct","fields":[{"name":"sender","type":"string"},{"name":"key","type":"string"},{"name":"value","type":"string"}]}},{"name":"DynamicEvent","type":{"kind":"struct","fields":[{"name":"key","type":"string"},{"name":"user_data","type":{"defined":{"name":"UserData"}}},{"name":"sender","type":"string"},{"name":"metadata","type":"bytes"},{"name":"metadata_array","type":{"vec":"bytes"}}]}},{"name":"NoFields","type":{"kind":"struct","fields":[]}},{"name":"UpdateReserves","type":{"kind":"struct","fields":[{"name":"total_minted","type":"u64"},{"name":"total_reserve","type":"u64"}]}},{"name":"UserData","type":{"kind":"struct","fields":[{"name":"key","type":"string"},{"name":"value","type":"string"}]}}]} as const + +// Base64 of the compact IDL JSON, passed to log triggers as contractIdlJson. +const DATA_STORAGE_IDL_BASE64 = 'eyJhZGRyZXNzIjoiRUNMODE0MmoyWVFBdnM5UjlnZVNzUm5rVkgyd0xFaTdzb0pDUnlKNzRjZkwiLCJtZXRhZGF0YSI6eyJuYW1lIjoiZGF0YV9zdG9yYWdlIiwidmVyc2lvbiI6IjAuMS4wIiwic3BlYyI6IjAuMS4wIiwiZGVzY3JpcHRpb24iOiJDcmVhdGVkIHdpdGggQW5jaG9yIn0sImluc3RydWN0aW9ucyI6W3sibmFtZSI6ImdldF9tdWx0aXBsZV9yZXNlcnZlcyIsImRpc2NyaW1pbmF0b3IiOlsxMDQsMTIyLDE0MCwxMDQsMTc1LDE1MSw3MCw0Ml0sImFjY291bnRzIjpbXSwiYXJncyI6W10sInJldHVybnMiOnsidmVjIjp7ImRlZmluZWQiOnsibmFtZSI6IlVwZGF0ZVJlc2VydmVzIn19fX0seyJuYW1lIjoiZ2V0X3Jlc2VydmVzIiwiZGlzY3JpbWluYXRvciI6WzEyMSwxNDAsMjM3LDg0LDIxOCwxMDUsNDgsMTddLCJhY2NvdW50cyI6W10sImFyZ3MiOltdLCJyZXR1cm5zIjp7ImRlZmluZWQiOnsibmFtZSI6IlVwZGF0ZVJlc2VydmVzIn19fSx7Im5hbWUiOiJnZXRfdHVwbGVfcmVzZXJ2ZXMiLCJkaXNjcmltaW5hdG9yIjpbMTg5LDgzLDE4NiwyMCwxMjcsODAsMTA5LDQ5XSwiYWNjb3VudHMiOltdLCJhcmdzIjpbXX0seyJuYW1lIjoiaW5pdGlhbGl6ZV9kYXRhX2FjY291bnQiLCJkaXNjcmltaW5hdG9yIjpbOSw2NCw3OCw0OSw3MSwxOTMsMTUsMjUwXSwiYWNjb3VudHMiOlt7Im5hbWUiOiJkYXRhX2FjY291bnQiLCJ3cml0YWJsZSI6dHJ1ZSwicGRhIjp7InNlZWRzIjpbeyJraW5kIjoiY29uc3QiLCJ2YWx1ZSI6WzEwMCw5NywxMTYsOTcsOTUsOTcsOTksOTksMTExLDExNywxMTAsMTE2XX0seyJraW5kIjoiYWNjb3VudCIsInBhdGgiOiJ1c2VyIn1dfX0seyJuYW1lIjoidXNlciIsIndyaXRhYmxlIjp0cnVlLCJzaWduZXIiOnRydWV9LHsibmFtZSI6InN5c3RlbV9wcm9ncmFtIiwiYWRkcmVzcyI6IjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIn1dLCJhcmdzIjpbeyJuYW1lIjoiaW5wdXQiLCJ0eXBlIjp7ImRlZmluZWQiOnsibmFtZSI6IlVzZXJEYXRhIn19fV19LHsibmFtZSI6ImxvZ19hY2Nlc3MiLCJkaXNjcmltaW5hdG9yIjpbMTk2LDU1LDE5NCwyNCw1LDIyNCwxNjEsMjA0XSwiYWNjb3VudHMiOlt7Im5hbWUiOiJ1c2VyIiwic2lnbmVyIjp0cnVlfV0sImFyZ3MiOlt7Im5hbWUiOiJtZXNzYWdlIiwidHlwZSI6InN0cmluZyJ9XX0seyJuYW1lIjoib25fcmVwb3J0IiwiZGlzY3JpbWluYXRvciI6WzIxNCwxNzMsMTgsMjIxLDE3MywxNDgsMTUxLDIwOF0sImFjY291bnRzIjpbeyJuYW1lIjoidXNlciIsIndyaXRhYmxlIjp0cnVlLCJzaWduZXIiOnRydWV9LHsibmFtZSI6ImRhdGFfYWNjb3VudCIsIndyaXRhYmxlIjp0cnVlLCJwZGEiOnsic2VlZHMiOlt7ImtpbmQiOiJjb25zdCIsInZhbHVlIjpbMTAwLDk3LDExNiw5Nyw5NSw5Nyw5OSw5OSwxMTEsMTE3LDExMCwxMTZdfSx7ImtpbmQiOiJhY2NvdW50IiwicGF0aCI6InVzZXIifV19fSx7Im5hbWUiOiJzeXN0ZW1fcHJvZ3JhbSIsImFkZHJlc3MiOiIxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSJ9XSwiYXJncyI6W3sibmFtZSI6Il9tZXRhZGF0YSIsInR5cGUiOiJieXRlcyJ9LHsibmFtZSI6InBheWxvYWQiLCJ0eXBlIjoiYnl0ZXMifV19LHsibmFtZSI6InVwZGF0ZV9rZXlfdmFsdWVfZGF0YSIsImRpc2NyaW1pbmF0b3IiOls2NywxMzcsMTQ0LDM1LDIxMCwxMjYsMjU0LDc5XSwiYWNjb3VudHMiOlt7Im5hbWUiOiJ1c2VyIiwid3JpdGFibGUiOnRydWUsInNpZ25lciI6dHJ1ZX0seyJuYW1lIjoiZGF0YV9hY2NvdW50Iiwid3JpdGFibGUiOnRydWUsInBkYSI6eyJzZWVkcyI6W3sia2luZCI6ImNvbnN0IiwidmFsdWUiOlsxMDAsOTcsMTE2LDk3LDk1LDk3LDk5LDk5LDExMSwxMTcsMTEwLDExNl19LHsia2luZCI6ImFjY291bnQiLCJwYXRoIjoidXNlciJ9XX19XSwiYXJncyI6W3sibmFtZSI6ImtleSIsInR5cGUiOiJzdHJpbmcifSx7Im5hbWUiOiJ2YWx1ZSIsInR5cGUiOiJzdHJpbmcifV19LHsibmFtZSI6InVwZGF0ZV91c2VyX2RhdGEiLCJkaXNjcmltaW5hdG9yIjpbMTEsMTMsMTE0LDE1MCwxOTQsMjI0LDE5Miw3OF0sImFjY291bnRzIjpbeyJuYW1lIjoidXNlciIsIndyaXRhYmxlIjp0cnVlLCJzaWduZXIiOnRydWV9LHsibmFtZSI6ImRhdGFfYWNjb3VudCIsIndyaXRhYmxlIjp0cnVlLCJwZGEiOnsic2VlZHMiOlt7ImtpbmQiOiJjb25zdCIsInZhbHVlIjpbMTAwLDk3LDExNiw5Nyw5NSw5Nyw5OSw5OSwxMTEsMTE3LDExMCwxMTZdfSx7ImtpbmQiOiJhY2NvdW50IiwicGF0aCI6InVzZXIifV19fV0sImFyZ3MiOlt7Im5hbWUiOiJpbnB1dCIsInR5cGUiOnsiZGVmaW5lZCI6eyJuYW1lIjoiVXNlckRhdGEifX19XX1dLCJhY2NvdW50cyI6W3sibmFtZSI6IkRhdGFBY2NvdW50IiwiZGlzY3JpbWluYXRvciI6Wzg1LDI0MCwxODIsMTU4LDc2LDcsMTgsMjMzXX1dLCJldmVudHMiOlt7Im5hbWUiOiJBY2Nlc3NMb2dnZWQiLCJkaXNjcmltaW5hdG9yIjpbMjQzLDUzLDIyNSw3MSw2NCwxMjAsMTA5LDI1XX0seyJuYW1lIjoiRHluYW1pY0V2ZW50IiwiZGlzY3JpbWluYXRvciI6WzIzNiwxNDUsMjI0LDE2MSw5LDIyMiwyMTgsMjM3XX0seyJuYW1lIjoiTm9GaWVsZHMiLCJkaXNjcmltaW5hdG9yIjpbMTYwLDE1Niw5NCw4NSw3NywxMjIsOTgsMjQwXX1dLCJlcnJvcnMiOlt7ImNvZGUiOjYwMDAsIm5hbWUiOiJEYXRhTm90Rm91bmQiLCJtc2ciOiJkYXRhIG5vdCBmb3VuZCJ9XSwidHlwZXMiOlt7Im5hbWUiOiJBY2Nlc3NMb2dnZWQiLCJ0eXBlIjp7ImtpbmQiOiJzdHJ1Y3QiLCJmaWVsZHMiOlt7Im5hbWUiOiJjYWxsZXIiLCJ0eXBlIjoicHVia2V5In0seyJuYW1lIjoibWVzc2FnZSIsInR5cGUiOiJzdHJpbmcifV19fSx7Im5hbWUiOiJEYXRhQWNjb3VudCIsInR5cGUiOnsia2luZCI6InN0cnVjdCIsImZpZWxkcyI6W3sibmFtZSI6InNlbmRlciIsInR5cGUiOiJzdHJpbmcifSx7Im5hbWUiOiJrZXkiLCJ0eXBlIjoic3RyaW5nIn0seyJuYW1lIjoidmFsdWUiLCJ0eXBlIjoic3RyaW5nIn1dfX0seyJuYW1lIjoiRHluYW1pY0V2ZW50IiwidHlwZSI6eyJraW5kIjoic3RydWN0IiwiZmllbGRzIjpbeyJuYW1lIjoia2V5IiwidHlwZSI6InN0cmluZyJ9LHsibmFtZSI6InVzZXJfZGF0YSIsInR5cGUiOnsiZGVmaW5lZCI6eyJuYW1lIjoiVXNlckRhdGEifX19LHsibmFtZSI6InNlbmRlciIsInR5cGUiOiJzdHJpbmcifSx7Im5hbWUiOiJtZXRhZGF0YSIsInR5cGUiOiJieXRlcyJ9LHsibmFtZSI6Im1ldGFkYXRhX2FycmF5IiwidHlwZSI6eyJ2ZWMiOiJieXRlcyJ9fV19fSx7Im5hbWUiOiJOb0ZpZWxkcyIsInR5cGUiOnsia2luZCI6InN0cnVjdCIsImZpZWxkcyI6W119fSx7Im5hbWUiOiJVcGRhdGVSZXNlcnZlcyIsInR5cGUiOnsia2luZCI6InN0cnVjdCIsImZpZWxkcyI6W3sibmFtZSI6InRvdGFsX21pbnRlZCIsInR5cGUiOiJ1NjQifSx7Im5hbWUiOiJ0b3RhbF9yZXNlcnZlIiwidHlwZSI6InU2NCJ9XX19LHsibmFtZSI6IlVzZXJEYXRhIiwidHlwZSI6eyJraW5kIjoic3RydWN0IiwiZmllbGRzIjpbeyJuYW1lIjoia2V5IiwidHlwZSI6InN0cmluZyJ9LHsibmFtZSI6InZhbHVlIiwidHlwZSI6InN0cmluZyJ9XX19XX0=' + +const DISCRIMINATOR_SIZE = 8 + +const expectDiscriminator = (label: string, expected: Uint8Array, data: Uint8Array): Uint8Array => { + if (data.length < DISCRIMINATOR_SIZE) { + throw new Error(`${label}: data too short for discriminator (${data.length} bytes)`) + } + for (let i = 0; i < DISCRIMINATOR_SIZE; i++) { + if (data[i] !== expected[i]) { + throw new Error(`${label}: discriminator mismatch`) + } + } + return data.subarray(DISCRIMINATOR_SIZE) +} + +export type AccessLogged = { + caller: Address + message: string +} + +export const accessLoggedCodec = getStructCodec([ + ['caller', getAddressCodec()], + ['message', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], +]) + +export type DataAccount = { + sender: string + key: string + value: string +} + +export const dataAccountCodec = getStructCodec([ + ['sender', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['key', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['value', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], +]) + +export type UserData = { + key: string + value: string +} + +export const userDataCodec = getStructCodec([ + ['key', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['value', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], +]) + +export type DynamicEvent = { + key: string + userData: UserData + sender: string + metadata: Uint8Array + metadataArray: Uint8Array[] +} + +export const dynamicEventCodec = getStructCodec([ + ['key', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['userData', userDataCodec], + ['sender', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['metadata', addCodecSizePrefix(getBytesCodec(), getU32Codec())], + ['metadataArray', getArrayCodec(addCodecSizePrefix(getBytesCodec(), getU32Codec()), { size: getU32Codec() })], +]) + +export type NoFields = Record + +export const noFieldsCodec = getStructCodec([]) + +export type UpdateReserves = { + totalMinted: bigint + totalReserve: bigint +} + +export const updateReservesCodec = getStructCodec([ + ['totalMinted', getU64Codec()], + ['totalReserve', getU64Codec()], +]) + +export const ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR = new Uint8Array([85, 240, 182, 158, 76, 7, 18, 233]) + +/** + * Decodes raw DataAccount account data (with its 8-byte discriminator) into DataAccount. + * Pure helper — there is no read capability; obtain the account bytes elsewhere. + */ +export const decodeDataAccountAccount = (data: Uint8Array): DataAccount => + dataAccountCodec.decode(expectDiscriminator('account DataAccount', ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR, data)) as DataAccount + +export const EVENT_ACCESS_LOGGED_DISCRIMINATOR = new Uint8Array([243, 53, 225, 71, 64, 120, 109, 25]) + +/** + * Decodes raw AccessLogged event data (with its 8-byte discriminator) into AccessLogged. + */ +export const decodeAccessLoggedEvent = (data: Uint8Array): AccessLogged => + accessLoggedCodec.decode(expectDiscriminator('event AccessLogged', EVENT_ACCESS_LOGGED_DISCRIMINATOR, data)) as AccessLogged + +export const EVENT_DYNAMIC_EVENT_DISCRIMINATOR = new Uint8Array([236, 145, 224, 161, 9, 222, 218, 237]) + +/** + * Decodes raw DynamicEvent event data (with its 8-byte discriminator) into DynamicEvent. + */ +export const decodeDynamicEventEvent = (data: Uint8Array): DynamicEvent => + dynamicEventCodec.decode(expectDiscriminator('event DynamicEvent', EVENT_DYNAMIC_EVENT_DISCRIMINATOR, data)) as DynamicEvent + +export const EVENT_NO_FIELDS_DISCRIMINATOR = new Uint8Array([160, 156, 94, 85, 77, 122, 98, 240]) + +/** + * Decodes raw NoFields event data (with its 8-byte discriminator) into NoFields. + */ +export const decodeNoFieldsEvent = (data: Uint8Array): NoFields => + noFieldsCodec.decode(expectDiscriminator('event NoFields', EVENT_NO_FIELDS_DISCRIMINATOR, data)) as NoFields + +export const parseAnyAccount = (data: Uint8Array): DataAccount => { + const disc = data.subarray(0, DISCRIMINATOR_SIZE) + const matches = (expected: Uint8Array) => expected.every((b, i) => disc[i] === b) + if (matches(ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR)) return decodeDataAccountAccount(data) + throw new Error(`unknown account discriminator: [${Array.from(disc).join(', ')}]`) +} + +export const parseAnyEvent = (data: Uint8Array): AccessLogged | DynamicEvent | NoFields => { + const disc = data.subarray(0, DISCRIMINATOR_SIZE) + const matches = (expected: Uint8Array) => expected.every((b, i) => disc[i] === b) + if (matches(EVENT_ACCESS_LOGGED_DISCRIMINATOR)) return decodeAccessLoggedEvent(data) + if (matches(EVENT_DYNAMIC_EVENT_DISCRIMINATOR)) return decodeDynamicEventEvent(data) + if (matches(EVENT_NO_FIELDS_DISCRIMINATOR)) return decodeNoFieldsEvent(data) + throw new Error(`unknown event discriminator: [${Array.from(disc).join(', ')}]`) +} + +/** + * Optional filter values for AccessLogged log triggers. Set a field to filter on + * that value (OR across filter rows). Leave unset for wildcard. Only top-level + * scalar fields with supported subkey encodings are auto-filterable — nested + * structs, vecs, arrays, bool, u128, and i128 need a manual SubkeyConfig. + */ +export type AccessLoggedFilters = { + caller?: Address | null + message?: string | null +} + +export const encodeAccessLoggedSubkeys = (filters: AccessLoggedFilters[]): SolanaSubkeyConfigJson[] => { + const callerComparers: SolanaValueComparatorJson[] = [] + const messageComparers: SolanaValueComparatorJson[] = [] + for (const f of filters) { + if (f.caller != null) { + callerComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(solanaAddressToBytes(f.caller)), + }) + } + if (f.message != null) { + messageComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(prepareSubkeyValue(f.message)), + }) + } + } + const subkeys: SolanaSubkeyConfigJson[] = [] + if (callerComparers.length > 0) { + subkeys.push({ path: ['Caller'], comparers: callerComparers }) + } + if (messageComparers.length > 0) { + subkeys.push({ path: ['Message'], comparers: messageComparers }) + } + return subkeys +} + +/** + * Optional filter values for DynamicEvent log triggers. Set a field to filter on + * that value (OR across filter rows). Leave unset for wildcard. Only top-level + * scalar fields with supported subkey encodings are auto-filterable — nested + * structs, vecs, arrays, bool, u128, and i128 need a manual SubkeyConfig. + */ +export type DynamicEventFilters = { + key?: string | null + sender?: string | null + metadata?: Uint8Array | null +} + +export const encodeDynamicEventSubkeys = (filters: DynamicEventFilters[]): SolanaSubkeyConfigJson[] => { + const keyComparers: SolanaValueComparatorJson[] = [] + const senderComparers: SolanaValueComparatorJson[] = [] + const metadataComparers: SolanaValueComparatorJson[] = [] + for (const f of filters) { + if (f.key != null) { + keyComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(prepareSubkeyValue(f.key)), + }) + } + if (f.sender != null) { + senderComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(prepareSubkeyValue(f.sender)), + }) + } + if (f.metadata != null) { + metadataComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(prepareSubkeyValue(f.metadata)), + }) + } + } + const subkeys: SolanaSubkeyConfigJson[] = [] + if (keyComparers.length > 0) { + subkeys.push({ path: ['Key'], comparers: keyComparers }) + } + if (senderComparers.length > 0) { + subkeys.push({ path: ['Sender'], comparers: senderComparers }) + } + if (metadataComparers.length > 0) { + subkeys.push({ path: ['Metadata'], comparers: metadataComparers }) + } + return subkeys +} + +/** + * Optional filter values for NoFields log triggers. Set a field to filter on + * that value (OR across filter rows). Leave unset for wildcard. Only top-level + * scalar fields with supported subkey encodings are auto-filterable — nested + * structs, vecs, arrays, bool, u128, and i128 need a manual SubkeyConfig. + */ +export type NoFieldsFilters = Record + +export const encodeNoFieldsSubkeys = (_filters: NoFieldsFilters[]): SolanaSubkeyConfigJson[] => [] + +export class DataStorage { + readonly programId: Uint8Array + + // The program ID is baked into the IDL, so it defaults to the generated + // const — unlike EVM bindings where the address is a runtime value. + constructor( + private readonly client: SolanaClient, + programId: string | Uint8Array = DATA_STORAGE_PROGRAM_ID, + ) { + this.programId = typeof programId === 'string' ? solanaAddressToBytes(programId) : programId + } + + /** + * Publishes a pre-encoded Borsh payload through the CRE signer to this + * program's on_report entrypoint via the keystone-forwarder. + * + * remainingAccounts must follow the keystone-forwarder account layout: + * - Index 0: forwarderState – the forwarder program's state account. + * - Index 1: forwarderAuthority – PDA derived from seeds + * ["forwarder", forwarderState, receiverProgram] under the forwarder program ID. + * - Index 2+: receiver-specific accounts required by the target program. + * + * The full account list is hashed (via calculateAccountsHash) into the report. + * The on-chain forwarder strips indices 0 and 1 before CPI-ing into the + * receiver, so they must be present and correctly ordered. + */ + writeReport( + runtime: Runtime, + payload: Uint8Array, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + const report = runtime + .report( + prepareSolanaReportRequest( + encodeForwarderReport({ + accountHash: calculateAccountsHash(remainingAccounts), + payload, + }), + ), + ) + .result() + + return this.client + .writeReport(runtime, { + remainingAccounts: solanaAccountMetasToJson(remainingAccounts), + receiver: bytesToHex(this.programId), + computeConfig, + report, + }) + .result() + } + + /** + * Publishes a Borsh Vec of pre-encoded element payloads (mirrors Go's + * WriteReportFromBorshEncodedVec). Each element must already be fully + * serialized for one Vec item on the wire. + */ + writeReportFromBorshEncodedVec( + runtime: Runtime, + elementPayloads: Uint8Array[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, encodeBorshVecU32(elementPayloads), remainingAccounts, computeConfig) + } + + writeReportFromAccessLogged( + runtime: Runtime, + input: AccessLogged, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(accessLoggedCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromAccessLoggeds( + runtime: Runtime, + inputs: AccessLogged[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(accessLoggedCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromDataAccount( + runtime: Runtime, + input: DataAccount, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(dataAccountCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromDataAccounts( + runtime: Runtime, + inputs: DataAccount[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(dataAccountCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromUserData( + runtime: Runtime, + input: UserData, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(userDataCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromUserDatas( + runtime: Runtime, + inputs: UserData[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(userDataCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromDynamicEvent( + runtime: Runtime, + input: DynamicEvent, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(dynamicEventCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromDynamicEvents( + runtime: Runtime, + inputs: DynamicEvent[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(dynamicEventCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromNoFields( + runtime: Runtime, + input: NoFields, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(noFieldsCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromNoFieldss( + runtime: Runtime, + inputs: NoFields[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(noFieldsCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromUpdateReserves( + runtime: Runtime, + input: UpdateReserves, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(updateReservesCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromUpdateReservess( + runtime: Runtime, + inputs: UpdateReserves[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(updateReservesCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + /** + * Registers a typed log trigger for AccessLogged events. The trigger + * output is adapted to the decoded AccessLogged data alongside the raw log. + * Pass opts.cpi for events emitted via Anchor's emit_cpi!. + */ + logTriggerAccessLoggedLog( + filterName: string, + filters: AccessLoggedFilters[] = [], + opts?: SolanaLogTriggerOptions, + ): Trigger> { + const config: SolanaFilterLogTriggerRequestJson = { + name: filterName, + address: bytesToBase64(this.programId), + eventName: 'AccessLogged', + contractIdlJson: DATA_STORAGE_IDL_BASE64, + subkeys: encodeAccessLoggedSubkeys(filters), + } + if (opts?.cpi) { + config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId) + } + return adaptTrigger(this.client.logTrigger(config), (log) => ({ + log, + data: decodeAccessLoggedEvent(log.data), + })) + } + + /** + * Registers a typed log trigger for DynamicEvent events. The trigger + * output is adapted to the decoded DynamicEvent data alongside the raw log. + * Pass opts.cpi for events emitted via Anchor's emit_cpi!. + */ + logTriggerDynamicEventLog( + filterName: string, + filters: DynamicEventFilters[] = [], + opts?: SolanaLogTriggerOptions, + ): Trigger> { + const config: SolanaFilterLogTriggerRequestJson = { + name: filterName, + address: bytesToBase64(this.programId), + eventName: 'DynamicEvent', + contractIdlJson: DATA_STORAGE_IDL_BASE64, + subkeys: encodeDynamicEventSubkeys(filters), + } + if (opts?.cpi) { + config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId) + } + return adaptTrigger(this.client.logTrigger(config), (log) => ({ + log, + data: decodeDynamicEventEvent(log.data), + })) + } + + /** + * Registers a typed log trigger for NoFields events. The trigger + * output is adapted to the decoded NoFields data alongside the raw log. + * Pass opts.cpi for events emitted via Anchor's emit_cpi!. + */ + logTriggerNoFieldsLog( + filterName: string, + filters: NoFieldsFilters[] = [], + opts?: SolanaLogTriggerOptions, + ): Trigger> { + const config: SolanaFilterLogTriggerRequestJson = { + name: filterName, + address: bytesToBase64(this.programId), + eventName: 'NoFields', + contractIdlJson: DATA_STORAGE_IDL_BASE64, + subkeys: encodeNoFieldsSubkeys(filters), + } + if (opts?.cpi) { + config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId) + } + return adaptTrigger(this.client.logTrigger(config), (log) => ({ + log, + data: decodeNoFieldsEvent(log.data), + })) + } +} diff --git a/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage_mock.ts b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage_mock.ts new file mode 100644 index 00000000..b4ed26b9 --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage_mock.ts @@ -0,0 +1,19 @@ +// Code generated — DO NOT EDIT. +import { addSolanaContractMock, type SolanaContractMock, type SolanaMock } from '@chainlink/cre-sdk/test' + +import { DATA_STORAGE_PROGRAM_ID } from './DataStorage' + +export type DataStorageMock = SolanaContractMock + +/** + * Registers a DataStorage program mock on a SolanaMock instance. + * The Solana CRE capability is write-only, so the mock routes writeReport + * calls targeting this program's ID; set the returned mock's writeReport + * property to define the reply. + */ +export function newDataStorageMock( + solanaMock: SolanaMock, + programId: string | Uint8Array = DATA_STORAGE_PROGRAM_ID, +): DataStorageMock { + return addSolanaContractMock(solanaMock, { programId }) +} diff --git a/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix.ts b/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix.ts new file mode 100644 index 00000000..0f005e97 --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix.ts @@ -0,0 +1,227 @@ +// Code generated — DO NOT EDIT. +import { + addCodecSizePrefix, + getArrayCodec, + getBooleanCodec, + getBytesCodec, + getEnumCodec, + getF32Codec, + getF64Codec, + getI128Codec, + getI16Codec, + getI32Codec, + getI64Codec, + getI8Codec, + getNullableCodec, + getStructCodec, + getU128Codec, + getU16Codec, + getU32Codec, + getU64Codec, + getU8Codec, + getUtf8Codec, +} from '@solana/codecs' +import { getAddressCodec, type Address } from '@solana/addresses' +import { + bytesToHex, + calculateAccountsHash, + encodeBorshVecU32, + encodeForwarderReport, + prepareSolanaReportRequest, + type Runtime, + type SolanaAccountMeta, + SolanaClient, + solanaAccountMetasToJson, + solanaAddressToBytes, + type SolanaComputeConfig, +} from '@chainlink/cre-sdk' + +export const FEATURE_MATRIX_PROGRAM_ID = 'ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL' + +export const FEATURE_MATRIX_IDL = {"address":"ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL","metadata":{"name":"feature_matrix","version":"0.1.0","spec":"0.1.0","description":"Fixture exercising the v1 TypeScript type-coverage matrix"},"instructions":[{"name":"on_report","discriminator":[214,173,18,221,173,148,151,208],"accounts":[],"args":[{"name":"_metadata","type":"bytes"},{"name":"payload","type":"bytes"}]}],"accounts":[],"events":[],"errors":[],"types":[{"name":"Color","type":{"kind":"enum","variants":[{"name":"Red"},{"name":"Green"},{"name":"Blue"}]}},{"name":"Inner","type":{"kind":"struct","fields":[{"name":"id","type":"u32"},{"name":"label","type":"string"}]}},{"name":"Everything","type":{"kind":"struct","fields":[{"name":"flag","type":"bool"},{"name":"tiny","type":"u8"},{"name":"tiny_signed","type":"i8"},{"name":"small","type":"u16"},{"name":"small_signed","type":"i16"},{"name":"medium","type":"u32"},{"name":"medium_signed","type":"i32"},{"name":"big","type":"u64"},{"name":"big_signed","type":"i64"},{"name":"huge","type":"u128"},{"name":"huge_signed","type":"i128"},{"name":"ratio","type":"f32"},{"name":"precise_ratio","type":"f64"},{"name":"title","type":"string"},{"name":"blob","type":"bytes"},{"name":"owner","type":"pubkey"},{"name":"scores","type":{"vec":"u64"}},{"name":"fixed_window","type":{"array":["u8",32]}},{"name":"maybe_note","type":{"option":"string"}},{"name":"nested","type":{"defined":{"name":"Inner"}}},{"name":"maybe_nested","type":{"option":{"defined":{"name":"Inner"}}}},{"name":"favorite_color","type":{"defined":{"name":"Color"}}},{"name":"color_history","type":{"vec":{"defined":{"name":"Color"}}}},{"name":"default","type":"bool"}]}}]} as const + +export enum Color { + Red = 0, + Green = 1, + Blue = 2, +} + +export const colorCodec = getEnumCodec(Color) + +export type Inner = { + id: number + label: string +} + +export const innerCodec = getStructCodec([ + ['id', getU32Codec()], + ['label', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], +]) + +export type Everything = { + flag: boolean + tiny: number + tinySigned: number + small: number + smallSigned: number + medium: number + mediumSigned: number + big: bigint + bigSigned: bigint + huge: bigint + hugeSigned: bigint + ratio: number + preciseRatio: number + title: string + blob: Uint8Array + owner: Address + scores: bigint[] + fixedWindow: number[] + maybeNote: string | null + nested: Inner + maybeNested: Inner | null + favoriteColor: Color + colorHistory: Color[] + default_: boolean +} + +export const everythingCodec = getStructCodec([ + ['flag', getBooleanCodec()], + ['tiny', getU8Codec()], + ['tinySigned', getI8Codec()], + ['small', getU16Codec()], + ['smallSigned', getI16Codec()], + ['medium', getU32Codec()], + ['mediumSigned', getI32Codec()], + ['big', getU64Codec()], + ['bigSigned', getI64Codec()], + ['huge', getU128Codec()], + ['hugeSigned', getI128Codec()], + ['ratio', getF32Codec()], + ['preciseRatio', getF64Codec()], + ['title', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['blob', addCodecSizePrefix(getBytesCodec(), getU32Codec())], + ['owner', getAddressCodec()], + ['scores', getArrayCodec(getU64Codec(), { size: getU32Codec() })], + ['fixedWindow', getArrayCodec(getU8Codec(), { size: 32 })], + ['maybeNote', getNullableCodec(addCodecSizePrefix(getUtf8Codec(), getU32Codec()))], + ['nested', innerCodec], + ['maybeNested', getNullableCodec(innerCodec)], + ['favoriteColor', colorCodec], + ['colorHistory', getArrayCodec(colorCodec, { size: getU32Codec() })], + ['default_', getBooleanCodec()], +]) + +export class FeatureMatrix { + readonly programId: Uint8Array + + // The program ID is baked into the IDL, so it defaults to the generated + // const — unlike EVM bindings where the address is a runtime value. + constructor( + private readonly client: SolanaClient, + programId: string | Uint8Array = FEATURE_MATRIX_PROGRAM_ID, + ) { + this.programId = typeof programId === 'string' ? solanaAddressToBytes(programId) : programId + } + + /** + * Publishes a pre-encoded Borsh payload through the CRE signer to this + * program's on_report entrypoint via the keystone-forwarder. + * + * remainingAccounts must follow the keystone-forwarder account layout: + * - Index 0: forwarderState – the forwarder program's state account. + * - Index 1: forwarderAuthority – PDA derived from seeds + * ["forwarder", forwarderState, receiverProgram] under the forwarder program ID. + * - Index 2+: receiver-specific accounts required by the target program. + * + * The full account list is hashed (via calculateAccountsHash) into the report. + * The on-chain forwarder strips indices 0 and 1 before CPI-ing into the + * receiver, so they must be present and correctly ordered. + */ + writeReport( + runtime: Runtime, + payload: Uint8Array, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + const report = runtime + .report( + prepareSolanaReportRequest( + encodeForwarderReport({ + accountHash: calculateAccountsHash(remainingAccounts), + payload, + }), + ), + ) + .result() + + return this.client + .writeReport(runtime, { + remainingAccounts: solanaAccountMetasToJson(remainingAccounts), + receiver: bytesToHex(this.programId), + computeConfig, + report, + }) + .result() + } + + /** + * Publishes a Borsh Vec of pre-encoded element payloads (mirrors Go's + * WriteReportFromBorshEncodedVec). Each element must already be fully + * serialized for one Vec item on the wire. + */ + writeReportFromBorshEncodedVec( + runtime: Runtime, + elementPayloads: Uint8Array[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, encodeBorshVecU32(elementPayloads), remainingAccounts, computeConfig) + } + + writeReportFromInner( + runtime: Runtime, + input: Inner, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(innerCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromInners( + runtime: Runtime, + inputs: Inner[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(innerCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromEverything( + runtime: Runtime, + input: Everything, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(everythingCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromEverythings( + runtime: Runtime, + inputs: Everything[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(everythingCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } +} diff --git a/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix_mock.ts b/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix_mock.ts new file mode 100644 index 00000000..20d01309 --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix_mock.ts @@ -0,0 +1,19 @@ +// Code generated — DO NOT EDIT. +import { addSolanaContractMock, type SolanaContractMock, type SolanaMock } from '@chainlink/cre-sdk/test' + +import { FEATURE_MATRIX_PROGRAM_ID } from './FeatureMatrix' + +export type FeatureMatrixMock = SolanaContractMock + +/** + * Registers a FeatureMatrix program mock on a SolanaMock instance. + * The Solana CRE capability is write-only, so the mock routes writeReport + * calls targeting this program's ID; set the returned mock's writeReport + * property to define the reply. + */ +export function newFeatureMatrixMock( + solanaMock: SolanaMock, + programId: string | Uint8Array = FEATURE_MATRIX_PROGRAM_ID, +): FeatureMatrixMock { + return addSolanaContractMock(solanaMock, { programId }) +} diff --git a/cmd/generate-bindings/solana/testdata/gen/main.go b/cmd/generate-bindings/solana/testdata/gen/main.go index 30dc025e..626fe72d 100644 --- a/cmd/generate-bindings/solana/testdata/gen/main.go +++ b/cmd/generate-bindings/solana/testdata/gen/main.go @@ -14,4 +14,20 @@ func main() { ); err != nil { log.Fatal(err) } + + // TypeScript goldens (compared byte-for-byte by TestGenerateBindingsTS_Golden). + if _, err := solana.GenerateBindingsTS( + "./testdata/contracts/idl/data_storage.json", + "data_storage", + "./testdata/data_storage_ts", + ); err != nil { + log.Fatal(err) + } + if _, err := solana.GenerateBindingsTS( + "./testdata/contracts/idl/feature_matrix.json", + "feature_matrix", + "./testdata/feature_matrix_ts", + ); err != nil { + log.Fatal(err) + } } diff --git a/cmd/generate-bindings/solana/tsbindgen.go b/cmd/generate-bindings/solana/tsbindgen.go new file mode 100644 index 00000000..25a8f22f --- /dev/null +++ b/cmd/generate-bindings/solana/tsbindgen.go @@ -0,0 +1,764 @@ +package solana + +import ( + "bytes" + _ "embed" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + "unicode" + + "github.com/gagliardetto/anchor-go/idl" + "github.com/gagliardetto/anchor-go/idl/idltype" +) + +//go:embed sourcecre.ts.tpl +var tsSolanaTpl string + +//go:embed mockcontract.ts.tpl +var tsSolanaMockTpl string + +var ( + tsSolanaTemplate = template.Must(template.New("sourcecre.ts").Parse(tsSolanaTpl)) + tsSolanaMockTemplate = template.Must(template.New("mockcontract.ts").Parse(tsSolanaMockTpl)) +) + +// jsReservedWords are identifiers that cannot be used as TypeScript/JavaScript +// names. Field names that collide get an underscore suffix; type/class names +// that collide are rejected (they come from IDL type names, which are expected +// to be PascalCase and never collide in practice). +var jsReservedWords = map[string]bool{ + "break": true, "case": true, "catch": true, "class": true, "const": true, + "continue": true, "debugger": true, "default": true, "delete": true, + "do": true, "else": true, "enum": true, "export": true, "extends": true, + "false": true, "finally": true, "for": true, "function": true, "if": true, + "import": true, "in": true, "instanceof": true, "new": true, "null": true, + "return": true, "super": true, "switch": true, "this": true, "throw": true, + "true": true, "try": true, "typeof": true, "var": true, "void": true, + "while": true, "with": true, "yield": true, "let": true, "static": true, + "implements": true, "interface": true, "package": true, "private": true, + "protected": true, "public": true, "await": true, "arguments": true, + "eval": true, +} + +type tsField struct { + Name string // camelCase TS property name + TSType string + CodecExpr string +} + +type tsEnumVariant struct { + Name string +} + +type tsTypeDef struct { + Name string // PascalCase TS type name + CodecConst string // e.g. userDataCodec + IsEnum bool + Fields []tsField + Variants []tsEnumVariant +} + +type tsDecoderDef struct { + Name string // PascalCase name from the IDL accounts/events section + TypeName string // TS type to decode into + CodecConst string + ConstName string // discriminator const, e.g. ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR + Discriminator string // JS array body, e.g. "85, 240, 182, ..." +} + +// tsTriggerFilterField is one auto-filterable event field of a log-trigger +// filters type. +type tsTriggerFilterField struct { + Name string // camelCase TS property name + TSType string // TS type of the filter value (option types unwrapped) + PathName string // PascalCase subkey path element (matches the Go bindings) + EncodeExpr string // TS expression producing the comparator value bytes from `f.` +} + +// tsTriggerDef describes the generated log-trigger surface for one IDL event. +type tsTriggerDef struct { + EventName string // IDL event name, sent verbatim as the request's eventName + Name string // PascalCase TS name + TypeName string // TS type the log data decodes into + FilterFields []tsTriggerFilterField +} + +type tsBindingData struct { + ProgramName string + ClassName string + MockName string + ProgramIDConst string + IdlConst string + IdlB64Const string + ProgramID string + IdlJSON string + IdlB64 string + CodecImports []string + UsesAddress bool + UsesSubkeyValue bool + UsesFloatSubkey bool + Types []tsTypeDef + StructTypes []tsTypeDef + Accounts []tsDecoderDef + Events []tsDecoderDef + Triggers []tsTriggerDef +} + +// GenerateBindingsTS generates the TypeScript CRE binding (.ts) and its +// mock (_mock.ts) for one Anchor IDL file. It mirrors the CRE-reachable +// surface of the Go generator: per-struct writeReportFrom(s) write +// methods, pure account/event decoders, and per-event log-trigger bindings +// (Filters + encodeSubkeys + logTriggerLog). Native +// instruction builders and account fetchers are intentionally not generated — +// they are unreachable through the Solana CRE capability. +func GenerateBindingsTS( + pathToIdl string, + programName string, + outDir string, +) (className string, err error) { + if pathToIdl == "" { + return "", fmt.Errorf("pathToIdl is empty") + } + if programName == "" { + return "", fmt.Errorf("programName is empty") + } + if outDir == "" { + return "", fmt.Errorf("outDir is empty") + } + + parsedIdl, err := idl.ParseFromFilepath(pathToIdl) + if err != nil { + return "", fmt.Errorf("failed to parse IDL: %w", err) + } + if parsedIdl == nil { + return "", fmt.Errorf("parsedIdl is nil") + } + if err := parsedIdl.Validate(); err != nil { + return "", fmt.Errorf("invalid IDL: %w", err) + } + rawIdl, err := os.ReadFile(pathToIdl) //nolint:gosec // G703 -- path from trusted CLI flags + if err != nil { + return "", fmt.Errorf("read IDL %q: %w", pathToIdl, err) + } + var compactIdl bytes.Buffer + if err := json.Compact(&compactIdl, rawIdl); err != nil { + return "", fmt.Errorf("compact IDL JSON %q: %w", pathToIdl, err) + } + + data, err := buildTSBindingData(parsedIdl, programName, compactIdl.String()) + if err != nil { + return "", err + } + + if err := os.MkdirAll(outDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + + if err := renderTSTemplate(tsSolanaTemplate, data, filepath.Join(outDir, data.ClassName+".ts")); err != nil { + return "", err + } + if err := renderTSTemplate(tsSolanaMockTemplate, data, filepath.Join(outDir, data.ClassName+"_mock.ts")); err != nil { + return "", err + } + + return data.ClassName, nil +} + +func renderTSTemplate(t *template.Template, data *tsBindingData, outPath string) error { + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return fmt.Errorf("render %q: %w", outPath, err) + } + if err := os.WriteFile(outPath, buf.Bytes(), 0o600); err != nil { //nolint:gosec // G703 -- derived from trusted CLI path + return fmt.Errorf("write %q: %w", outPath, err) + } + return nil +} + +func buildTSBindingData(parsedIdl *idl.Idl, programName, idlJSON string) (*tsBindingData, error) { + mapper := &tsTypeMapper{ + defs: map[string]*idl.IdlTypeDef{}, + imports: map[string]bool{}, + } + for i := range parsedIdl.Types { + def := &parsedIdl.Types[i] + if _, exists := mapper.defs[def.Name]; exists { + return nil, fmt.Errorf("duplicate type %q in IDL", def.Name) + } + mapper.defs[def.Name] = def + } + + ordered, err := topoSortTypes(parsedIdl.Types) + if err != nil { + return nil, err + } + + className := toPascalCase(programName) + if jsReservedWords[className] || !isValidJSIdentifier(className) { + return nil, fmt.Errorf("program name %q maps to invalid TypeScript class name %q", programName, className) + } + + programID := "" + if parsedIdl.Address != nil && !parsedIdl.Address.IsZero() { + programID = parsedIdl.Address.String() + } + data := &tsBindingData{ + ProgramName: programName, + ClassName: className, + MockName: className + "Mock", + ProgramIDConst: toUpperSnake(programName) + "_PROGRAM_ID", + IdlConst: toUpperSnake(programName) + "_IDL", + IdlB64Const: toUpperSnake(programName) + "_IDL_BASE64", + ProgramID: programID, + IdlJSON: idlJSON, + IdlB64: base64.StdEncoding.EncodeToString([]byte(idlJSON)), + } + + typeNames := map[string]string{} // TS name -> IDL name (collision detection) + codecNames := map[string]bool{} + for _, def := range ordered { + tsName := toPascalCase(def.Name) + if prev, exists := typeNames[tsName]; exists { + return nil, fmt.Errorf("type name collision: IDL types %q and %q both map to TypeScript name %q", prev, def.Name, tsName) + } + typeNames[tsName] = def.Name + + tsDef, err := mapper.mapTypeDef(def, tsName) + if err != nil { + return nil, err + } + if codecNames[tsDef.CodecConst] { + return nil, fmt.Errorf("codec name collision: %q generated twice", tsDef.CodecConst) + } + codecNames[tsDef.CodecConst] = true + + data.Types = append(data.Types, *tsDef) + if !tsDef.IsEnum { + data.StructTypes = append(data.StructTypes, *tsDef) + } + } + + // Per-struct write method names must not collide (e.g. types Foo and Foos + // would both produce writeReportFromFoos). + writeMethods := map[string]string{} + for _, st := range data.StructTypes { + for _, method := range []string{"writeReportFrom" + st.Name, "writeReportFrom" + st.Name + "s"} { + if prev, exists := writeMethods[method]; exists { + return nil, fmt.Errorf("write method name collision: types %q and %q both produce %q", prev, st.Name, method) + } + writeMethods[method] = st.Name + } + } + + typesByName := make(map[string]tsTypeDef, len(data.Types)) + for _, td := range data.Types { + typesByName[td.Name] = td + } + + for _, account := range parsedIdl.Accounts { + decoder, err := buildDecoderDef("account", account.Name, account.Discriminator[:], typesByName) + if err != nil { + return nil, err + } + data.Accounts = append(data.Accounts, *decoder) + } + for _, event := range parsedIdl.Events { + decoder, err := buildDecoderDef("event", event.Name, event.Discriminator[:], typesByName) + if err != nil { + return nil, err + } + data.Events = append(data.Events, *decoder) + + trigger, err := buildTriggerDef(event.Name, decoder, mapper, typeNames) + if err != nil { + return nil, err + } + data.Triggers = append(data.Triggers, *trigger) + } + + data.CodecImports = mapper.sortedImports() + data.UsesAddress = mapper.usesAddress + data.UsesSubkeyValue = mapper.usesSubkeyValue + data.UsesFloatSubkey = mapper.usesFloatSubkey + + return data, nil +} + +// buildTriggerDef builds the log-trigger binding data for one IDL event, +// mirroring the Go generator's trigger bindings: only top-level scalar fields +// with supported subkey encodings become filter fields (nested structs, vecs, +// arrays, bool, u128, and i128 need a manual SubkeyConfig). +func buildTriggerDef( + eventName string, + decoder *tsDecoderDef, + mapper *tsTypeMapper, + typeNames map[string]string, +) (*tsTriggerDef, error) { + filtersTypeName := decoder.Name + "Filters" + if prev, exists := typeNames[filtersTypeName]; exists { + return nil, fmt.Errorf("event %q: filters type %q collides with IDL type %q", eventName, filtersTypeName, prev) + } + + trigger := &tsTriggerDef{ + EventName: eventName, + Name: decoder.Name, + TypeName: decoder.TypeName, + } + + def, exists := mapper.defs[eventName] + if !exists { + // buildDecoderDef resolved the type via toPascalCase; find it the same way. + for idlName := range mapper.defs { + if toPascalCase(idlName) == decoder.Name { + def = mapper.defs[idlName] + break + } + } + } + if def == nil { + return nil, fmt.Errorf("event %q has no matching type definition in the IDL types section", eventName) + } + + structTy, ok := def.Ty.(*idl.IdlTypeDefTyStruct) + if !ok { + return nil, fmt.Errorf("event %q: type %q is not a struct", eventName, def.Name) + } + var named idl.IdlDefinedFieldsNamed + if structTy.Fields != nil { + named, ok = structTy.Fields.(idl.IdlDefinedFieldsNamed) + if !ok { + return nil, fmt.Errorf("event %q: tuple struct fields are not supported by the TypeScript generator yet", eventName) + } + } + + seen := map[string]string{} + for _, field := range named { + fieldName := toCamelCase(field.Name) + if jsReservedWords[fieldName] { + fieldName += "_" + } + tsType, encodeExpr, ok, err := mapper.filterField(field.Ty, "f."+fieldName, fmt.Sprintf("%s.%s", def.Name, field.Name)) + if err != nil { + return nil, err + } + if !ok { + continue + } + if prev, exists := seen[fieldName]; exists { + return nil, fmt.Errorf("event %q: fields %q and %q both map to %q", eventName, prev, field.Name, fieldName) + } + seen[fieldName] = field.Name + + trigger.FilterFields = append(trigger.FilterFields, tsTriggerFilterField{ + Name: fieldName, + TSType: tsType, + PathName: toPascalCase(field.Name), + EncodeExpr: encodeExpr, + }) + } + + return trigger, nil +} + +func buildDecoderDef(kind, name string, discriminator []byte, typesByName map[string]tsTypeDef) (*tsDecoderDef, error) { + tsName := toPascalCase(name) + def, exists := typesByName[tsName] + if !exists { + return nil, fmt.Errorf("%s %q has no matching type definition in the IDL types section", kind, name) + } + if def.IsEnum { + return nil, fmt.Errorf("%s %q refers to enum type %q; only struct %ss are supported", kind, name, tsName, kind) + } + parts := make([]string, len(discriminator)) + for i, b := range discriminator { + parts[i] = fmt.Sprintf("%d", b) + } + return &tsDecoderDef{ + Name: tsName, + TypeName: tsName, + CodecConst: def.CodecConst, + ConstName: strings.ToUpper(kind) + "_" + toUpperSnake(name) + "_DISCRIMINATOR", + Discriminator: strings.Join(parts, ", "), + }, nil +} + +// topoSortTypes orders type definitions so that every `defined` reference +// points to an already-emitted codec const. Cyclic type references cannot be +// expressed with plain codec consts and fail loudly. +func topoSortTypes(types idl.IdTypeDef_slice) ([]*idl.IdlTypeDef, error) { + defsByName := make(map[string]*idl.IdlTypeDef, len(types)) + deps := map[string][]string{} + for i := range types { + def := &types[i] + defsByName[def.Name] = def + refs, err := definedRefsOfTypeDef(def) + if err != nil { + return nil, err + } + deps[def.Name] = refs + } + + var ordered []*idl.IdlTypeDef + state := map[string]int{} // 0 unvisited, 1 visiting, 2 done + var visit func(name string, path []string) error + visit = func(name string, path []string) error { + switch state[name] { + case 1: + return fmt.Errorf("cyclic type reference in IDL: %s", strings.Join(append(path, name), " -> ")) + case 2: + return nil + } + state[name] = 1 + for _, dep := range deps[name] { + if _, known := deps[dep]; !known { + return fmt.Errorf("type %q references undefined type %q", name, dep) + } + if err := visit(dep, append(path, name)); err != nil { + return err + } + } + state[name] = 2 + ordered = append(ordered, defsByName[name]) + return nil + } + + for i := range types { + if err := visit(types[i].Name, nil); err != nil { + return nil, err + } + } + return ordered, nil +} + +func definedRefsOfTypeDef(def *idl.IdlTypeDef) ([]string, error) { + var refs []string + var walk func(t idltype.IdlType) + walk = func(t idltype.IdlType) { + switch typed := t.(type) { + case *idltype.Option: + walk(typed.Option) + case *idltype.COption: + walk(typed.COption) + case *idltype.Vec: + walk(typed.Vec) + case *idltype.Array: + walk(typed.Type) + case *idltype.Defined: + refs = append(refs, typed.Name) + } + } + + switch ty := def.Ty.(type) { + case *idl.IdlTypeDefTyStruct: + switch fields := ty.Fields.(type) { + case nil: + // empty struct + case idl.IdlDefinedFieldsNamed: + for _, field := range fields { + walk(field.Ty) + } + default: + return nil, fmt.Errorf("type %q: tuple struct fields are not supported by the TypeScript generator yet", def.Name) + } + case *idl.IdlTypeDefTyEnum: + if !ty.IsAllSimple() { + return nil, fmt.Errorf("type %q: data-carrying enums are not supported by the TypeScript generator yet (only scalar enums)", def.Name) + } + default: + return nil, fmt.Errorf("type %q: unsupported type definition kind %T", def.Name, def.Ty) + } + return refs, nil +} + +type tsTypeMapper struct { + defs map[string]*idl.IdlTypeDef + imports map[string]bool + usesAddress bool + usesSubkeyValue bool + usesFloatSubkey bool +} + +// filterField maps an event field to its TS filter-value type and the +// expression encoding `access` (e.g. "f.caller") into the comparator value +// bytes. ok=false means the field is not auto-filterable (mirrors the Go +// generator's isFilterableField: scalars only, minus bool and 128/256-bit +// integers; option wrappers are unwrapped). Encodings match the Go bindings' +// PrepareSubkeyValue. +func (m *tsTypeMapper) filterField(t idltype.IdlType, access, owner string) (tsType, encodeExpr string, ok bool, err error) { + switch typed := t.(type) { + case *idltype.Option: + return m.filterField(typed.Option, access, owner) + case *idltype.COption: + return m.filterField(typed.COption, access, owner) + case *idltype.U8, *idltype.I8, *idltype.U16, *idltype.I16, *idltype.U32, *idltype.I32, + *idltype.U64, *idltype.I64, *idltype.String, *idltype.Bytes: + m.usesSubkeyValue = true + encodeExpr = fmt.Sprintf("prepareSubkeyValue(%s)", access) + case *idltype.F32, *idltype.F64: + m.usesFloatSubkey = true + encodeExpr = fmt.Sprintf("prepareSubkeyFloatValue(%s)", access) + case *idltype.Pubkey: + encodeExpr = fmt.Sprintf("solanaAddressToBytes(%s)", access) + default: + return "", "", false, nil + } + // t is unwrapped here; mapType owns the TS type (and usesAddress for pubkey). + tsType, _, err = m.mapType(t, owner) + if err != nil { + return "", "", false, err + } + return tsType, encodeExpr, true, nil +} + +func (m *tsTypeMapper) sortedImports() []string { + out := make([]string, 0, len(m.imports)) + for name := range m.imports { + out = append(out, name) + } + sort.Strings(out) + return out +} + +func (m *tsTypeMapper) mapTypeDef(def *idl.IdlTypeDef, tsName string) (*tsTypeDef, error) { + codecConst := toCamelCase(def.Name) + "Codec" + + switch ty := def.Ty.(type) { + case *idl.IdlTypeDefTyEnum: + // definedRefsOfTypeDef already rejected data-carrying enums. + m.imports["getEnumCodec"] = true + out := &tsTypeDef{Name: tsName, CodecConst: codecConst, IsEnum: true} + seen := map[string]string{} + for _, variant := range ty.Variants { + variantName := toPascalCase(variant.Name) + if prev, exists := seen[variantName]; exists { + return nil, fmt.Errorf("type %q: enum variants %q and %q both map to %q", def.Name, prev, variant.Name, variantName) + } + seen[variantName] = variant.Name + out.Variants = append(out.Variants, tsEnumVariant{Name: variantName}) + } + return out, nil + + case *idl.IdlTypeDefTyStruct: + m.imports["getStructCodec"] = true + out := &tsTypeDef{Name: tsName, CodecConst: codecConst} + var named idl.IdlDefinedFieldsNamed + if ty.Fields != nil { + var ok bool + named, ok = ty.Fields.(idl.IdlDefinedFieldsNamed) + if !ok { + return nil, fmt.Errorf("type %q: tuple struct fields are not supported by the TypeScript generator yet", def.Name) + } + } + seen := map[string]string{} + for _, field := range named { + fieldName := toCamelCase(field.Name) + if jsReservedWords[fieldName] { + fieldName += "_" + } + if !isValidJSIdentifier(fieldName) { + return nil, fmt.Errorf("type %q: field %q maps to invalid TypeScript identifier %q", def.Name, field.Name, fieldName) + } + if prev, exists := seen[fieldName]; exists { + return nil, fmt.Errorf("type %q: fields %q and %q both map to %q", def.Name, prev, field.Name, fieldName) + } + seen[fieldName] = field.Name + + tsType, codecExpr, err := m.mapType(field.Ty, fmt.Sprintf("%s.%s", def.Name, field.Name)) + if err != nil { + return nil, err + } + out.Fields = append(out.Fields, tsField{Name: fieldName, TSType: tsType, CodecExpr: codecExpr}) + } + return out, nil + + default: + return nil, fmt.Errorf("type %q: unsupported type definition kind %T", def.Name, def.Ty) + } +} + +// mapType maps an Anchor IDL type to its TypeScript type and the +// @solana/codecs expression that encodes/decodes it (the v1 coverage matrix). +// Unsupported types fail loudly instead of silently mis-encoding. +func (m *tsTypeMapper) mapType(t idltype.IdlType, owner string) (tsType, codecExpr string, err error) { + simple := func(ts, codecFn string) (string, string, error) { + m.imports[codecFn] = true + return ts, codecFn + "()", nil + } + + switch typed := t.(type) { + case *idltype.Bool: + return simple("boolean", "getBooleanCodec") + case *idltype.U8: + return simple("number", "getU8Codec") + case *idltype.I8: + return simple("number", "getI8Codec") + case *idltype.U16: + return simple("number", "getU16Codec") + case *idltype.I16: + return simple("number", "getI16Codec") + case *idltype.U32: + return simple("number", "getU32Codec") + case *idltype.I32: + return simple("number", "getI32Codec") + case *idltype.U64: + return simple("bigint", "getU64Codec") + case *idltype.I64: + return simple("bigint", "getI64Codec") + case *idltype.U128: + return simple("bigint", "getU128Codec") + case *idltype.I128: + return simple("bigint", "getI128Codec") + case *idltype.F32: + return simple("number", "getF32Codec") + case *idltype.F64: + return simple("number", "getF64Codec") + case *idltype.U256, *idltype.I256: + return "", "", fmt.Errorf("%s: %s is not supported by the TypeScript generator yet (no @solana/codecs codec; needs a hand-written 32-byte little-endian codec)", owner, t.String()) + case *idltype.String: + m.imports["addCodecSizePrefix"] = true + m.imports["getUtf8Codec"] = true + m.imports["getU32Codec"] = true + return "string", "addCodecSizePrefix(getUtf8Codec(), getU32Codec())", nil + case *idltype.Bytes: + m.imports["addCodecSizePrefix"] = true + m.imports["getBytesCodec"] = true + m.imports["getU32Codec"] = true + return "Uint8Array", "addCodecSizePrefix(getBytesCodec(), getU32Codec())", nil + case *idltype.Pubkey: + m.usesAddress = true + return "Address", "getAddressCodec()", nil + case *idltype.Option: + innerTS, innerCodec, err := m.mapType(typed.Option, owner) + if err != nil { + return "", "", err + } + m.imports["getNullableCodec"] = true + return fmt.Sprintf("%s | null", innerTS), fmt.Sprintf("getNullableCodec(%s)", innerCodec), nil + case *idltype.COption: + return "", "", fmt.Errorf("%s: COption is not supported by the TypeScript generator yet", owner) + case *idltype.Vec: + innerTS, innerCodec, err := m.mapType(typed.Vec, owner) + if err != nil { + return "", "", err + } + m.imports["getArrayCodec"] = true + m.imports["getU32Codec"] = true + return arrayTSType(innerTS), fmt.Sprintf("getArrayCodec(%s, { size: getU32Codec() })", innerCodec), nil + case *idltype.Array: + size, ok := typed.Size.(*idltype.IdlArrayLenValue) + if !ok { + return "", "", fmt.Errorf("%s: generic array lengths are not supported by the TypeScript generator yet", owner) + } + innerTS, innerCodec, err := m.mapType(typed.Type, owner) + if err != nil { + return "", "", err + } + m.imports["getArrayCodec"] = true + return arrayTSType(innerTS), fmt.Sprintf("getArrayCodec(%s, { size: %d })", innerCodec, size.Value), nil + case *idltype.Defined: + if len(typed.Generics) > 0 { + return "", "", fmt.Errorf("%s: generic type %q is not supported by the TypeScript generator yet", owner, typed.Name) + } + def, exists := m.defs[typed.Name] + if !exists { + return "", "", fmt.Errorf("%s: references undefined type %q", owner, typed.Name) + } + return toPascalCase(def.Name), toCamelCase(def.Name) + "Codec", nil + case *idltype.Generic: + return "", "", fmt.Errorf("%s: generic types are not supported by the TypeScript generator yet", owner) + default: + return "", "", fmt.Errorf("%s: unsupported IDL type %T", owner, t) + } +} + +// arrayTSType wraps union element types in parentheses so `T | null` becomes +// `(T | null)[]` instead of the wrong `T | null[]`. +func arrayTSType(inner string) string { + if strings.Contains(inner, "|") { + return "(" + inner + ")[]" + } + return inner + "[]" +} + +func splitNameWords(name string) []string { + var words []string + var current []rune + flush := func() { + if len(current) > 0 { + words = append(words, string(current)) + current = nil + } + } + runes := []rune(name) + for i, r := range runes { + switch { + case r == '_' || r == '-' || r == ' ': + flush() + case unicode.IsUpper(r): + // Split on lower→Upper and on the last upper of an acronym + // followed by lower (e.g. HTTPServer -> HTTP, Server). + if i > 0 && (unicode.IsLower(runes[i-1]) || unicode.IsDigit(runes[i-1])) { + flush() + } else if i > 0 && unicode.IsUpper(runes[i-1]) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) { + flush() + } + current = append(current, r) + default: + current = append(current, r) + } + } + flush() + return words +} + +func toPascalCase(name string) string { + var b strings.Builder + for _, word := range splitNameWords(name) { + runes := []rune(strings.ToLower(word)) + runes[0] = unicode.ToUpper(runes[0]) + b.WriteString(string(runes)) + } + return b.String() +} + +func toCamelCase(name string) string { + pascal := toPascalCase(name) + if pascal == "" { + return "" + } + runes := []rune(pascal) + runes[0] = unicode.ToLower(runes[0]) + return string(runes) +} + +func toUpperSnake(name string) string { + words := splitNameWords(name) + for i, word := range words { + words[i] = strings.ToUpper(word) + } + return strings.Join(words, "_") +} + +func isValidJSIdentifier(name string) bool { + if name == "" { + return false + } + for i, r := range name { + if i == 0 { + if !unicode.IsLetter(r) && r != '_' && r != '$' { + return false + } + continue + } + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '$' { + return false + } + } + return true +} diff --git a/cmd/generate-bindings/solana/tsbindgen_test.go b/cmd/generate-bindings/solana/tsbindgen_test.go new file mode 100644 index 00000000..6b1d2689 --- /dev/null +++ b/cmd/generate-bindings/solana/tsbindgen_test.go @@ -0,0 +1,337 @@ +package solana + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeTestIdlNoAddress writes a minimal valid IDL without an address field. +func writeTestIdlNoAddress(t *testing.T) string { + t.Helper() + idl := `{ + "metadata": {"name": "no_addr", "version": "0.1.0", "spec": "0.1.0"}, + "instructions": [ + {"name": "on_report", "discriminator": [214,173,18,221,173,148,151,208], "accounts": [], "args": []} + ], + "accounts": [], + "events": [], + "errors": [], + "types": [] +}` + path := filepath.Join(t.TempDir(), "no_addr.json") + require.NoError(t, os.WriteFile(path, []byte(idl), 0o600)) + return path +} + +// writeTestIdl writes a minimal valid IDL with the given types JSON fragment +// and returns its path. +func writeTestIdl(t *testing.T, typesJSON string) string { + t.Helper() + idl := `{ + "address": "ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL", + "metadata": {"name": "fail_loud", "version": "0.1.0", "spec": "0.1.0"}, + "instructions": [ + {"name": "on_report", "discriminator": [214,173,18,221,173,148,151,208], "accounts": [], "args": []} + ], + "accounts": [], + "events": [], + "errors": [], + "types": ` + typesJSON + ` +}` + path := filepath.Join(t.TempDir(), "fail_loud.json") + require.NoError(t, os.WriteFile(path, []byte(idl), 0o600)) + return path +} + +// TestGenerateBindingsTS_Golden regenerates the TypeScript bindings for the +// checked-in fixture IDLs and compares them byte-for-byte against the golden +// files. Regenerate goldens with `go generate ./cmd/generate-bindings/solana`. +func TestGenerateBindingsTS_Golden(t *testing.T) { + cases := []struct { + program string + className string + goldenDir string + }{ + {program: "data_storage", className: "DataStorage", goldenDir: "testdata/data_storage_ts"}, + {program: "feature_matrix", className: "FeatureMatrix", goldenDir: "testdata/feature_matrix_ts"}, + } + + for _, tc := range cases { + t.Run(tc.program, func(t *testing.T) { + outDir := t.TempDir() + className, err := GenerateBindingsTS( + filepath.Join("testdata", "contracts", "idl", tc.program+".json"), + tc.program, + outDir, + ) + require.NoError(t, err) + assert.Equal(t, tc.className, className) + + for _, file := range []string{tc.className + ".ts", tc.className + "_mock.ts"} { + generated, err := os.ReadFile(filepath.Join(outDir, file)) + require.NoError(t, err) + golden, err := os.ReadFile(filepath.Join(tc.goldenDir, file)) + require.NoError(t, err) + assert.Equal(t, string(golden), string(generated), + "%s differs from golden; regenerate with `go generate ./cmd/generate-bindings/solana` if the change is intended", file) + } + }) + } +} + +func TestGenerateBindingsTS_GeneratedContent(t *testing.T) { + outDir := t.TempDir() + _, err := GenerateBindingsTS( + filepath.Join("testdata", "contracts", "idl", "feature_matrix.json"), + "feature_matrix", + outDir, + ) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(outDir, "FeatureMatrix.ts")) + require.NoError(t, err) + source := string(content) + + // Spot-check the v1 type-coverage matrix mappings. + assert.Contains(t, source, "export enum Color {") + assert.Contains(t, source, "getEnumCodec(Color)") + assert.Contains(t, source, "huge: bigint") + assert.Contains(t, source, "getU128Codec()") + assert.Contains(t, source, "owner: Address") + assert.Contains(t, source, "getAddressCodec()") + assert.Contains(t, source, "maybeNote: string | null") + assert.Contains(t, source, "getNullableCodec(addCodecSizePrefix(getUtf8Codec(), getU32Codec()))") + assert.Contains(t, source, "getArrayCodec(getU8Codec(), { size: 32 })") + assert.Contains(t, source, "['nested', innerCodec],") + assert.Contains(t, source, "maybeNested: Inner | null") + assert.Contains(t, source, "colorHistory: Color[]") + // JS reserved word field gets an underscore suffix. + assert.Contains(t, source, "default_: boolean") + // Per-struct write methods (single and slice). + assert.Contains(t, source, "writeReportFromEverything(") + assert.Contains(t, source, "writeReportFromEverythings(") + assert.Contains(t, source, "writeReportFromBorshEncodedVec(") + // No dispatch functions for feature_matrix (no accounts or events in IDL). + assert.NotContains(t, source, "parseAnyAccount") + assert.NotContains(t, source, "parseAnyEvent") +} + +func TestGenerateBindingsTS_DispatchFunctions(t *testing.T) { + outDir := t.TempDir() + _, err := GenerateBindingsTS( + filepath.Join("testdata", "contracts", "idl", "data_storage.json"), + "data_storage", + outDir, + ) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(outDir, "DataStorage.ts")) + require.NoError(t, err) + source := string(content) + + assert.Contains(t, source, "export const parseAnyAccount = (data: Uint8Array): DataAccount =>") + assert.Contains(t, source, "export const parseAnyEvent = (data: Uint8Array): AccessLogged | DynamicEvent | NoFields =>") + assert.Contains(t, source, "if (matches(ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR)) return decodeDataAccountAccount(data)") + assert.Contains(t, source, "if (matches(EVENT_ACCESS_LOGGED_DISCRIMINATOR)) return decodeAccessLoggedEvent(data)") + assert.Contains(t, source, "unknown account discriminator") + assert.Contains(t, source, "unknown event discriminator") +} + +func TestGenerateBindingsTS_LogTriggers(t *testing.T) { + outDir := t.TempDir() + _, err := GenerateBindingsTS( + filepath.Join("testdata", "contracts", "idl", "data_storage.json"), + "data_storage", + outDir, + ) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(outDir, "DataStorage.ts")) + require.NoError(t, err) + source := string(content) + + // Per-event filters type, subkey encoder, and typed trigger method. + assert.Contains(t, source, "export type AccessLoggedFilters = {") + assert.Contains(t, source, "caller?: Address | null") + assert.Contains(t, source, "export const encodeAccessLoggedSubkeys = (filters: AccessLoggedFilters[]): SolanaSubkeyConfigJson[] =>") + assert.Contains(t, source, "logTriggerAccessLoggedLog(") + assert.Contains(t, source, "): Trigger> {") + // Subkey paths use the Go bindings' PascalCase names. + assert.Contains(t, source, "subkeys.push({ path: ['Caller'], comparers: callerComparers })") + // Pubkey filter values are converted from base58 before encoding. + assert.Contains(t, source, "value: bytesToBase64(solanaAddressToBytes(f.caller))") + // The compact IDL is embedded once as base64 and sent as contractIdlJson. + assert.Contains(t, source, "const DATA_STORAGE_IDL_BASE64 = '") + assert.Contains(t, source, "contractIdlJson: DATA_STORAGE_IDL_BASE64,") + // CPI opt-in wires the anchor:event self-CPI filter. + assert.Contains(t, source, "config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId)") + // The decoded data rides along with the raw log. + assert.Contains(t, source, "data: decodeAccessLoggedEvent(log.data),") + // Non-filterable fields (vec, defined) are excluded from DynamicEvent filters. + assert.NotContains(t, source, "userData?:") + assert.NotContains(t, source, "metadataArray?:") + // An event with no filterable fields still gets a trigger with empty filters. + assert.Contains(t, source, "export type NoFieldsFilters = Record") + assert.Contains(t, source, "export const encodeNoFieldsSubkeys = (_filters: NoFieldsFilters[]): SolanaSubkeyConfigJson[] => []") + assert.Contains(t, source, "logTriggerNoFieldsLog(") + + // An IDL without events must not emit trigger code or its imports. + fmOut := t.TempDir() + _, err = GenerateBindingsTS( + filepath.Join("testdata", "contracts", "idl", "feature_matrix.json"), + "feature_matrix", + fmOut, + ) + require.NoError(t, err) + fmContent, err := os.ReadFile(filepath.Join(fmOut, "FeatureMatrix.ts")) + require.NoError(t, err) + assert.NotContains(t, string(fmContent), "logTrigger") + assert.NotContains(t, string(fmContent), "anchorCPILogTriggerConfig") + assert.NotContains(t, string(fmContent), "prepareSubkeyValue") +} + +func TestGenerateBindingsTS_TriggerFilterEncodings(t *testing.T) { + idl := `{ + "address": "ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL", + "metadata": {"name": "encodings", "version": "0.1.0", "spec": "0.1.0"}, + "instructions": [ + {"name": "on_report", "discriminator": [214,173,18,221,173,148,151,208], "accounts": [], "args": []} + ], + "accounts": [], + "events": [{"name": "Mixed", "discriminator": [1,2,3,4,5,6,7,8]}], + "errors": [], + "types": [{"name": "Mixed", "type": {"kind": "struct", "fields": [ + {"name": "small", "type": "u8"}, + {"name": "amount", "type": "u64"}, + {"name": "ratio", "type": "f64"}, + {"name": "maybe_tag", "type": {"option": "string"}}, + {"name": "flag", "type": "bool"}, + {"name": "huge", "type": "u128"}, + {"name": "blob", "type": "bytes"}, + {"name": "list", "type": {"vec": "u8"}}, + {"name": "fixed", "type": {"array": ["u8", 32]}} + ]}}] +}` + idlPath := filepath.Join(t.TempDir(), "encodings.json") + require.NoError(t, os.WriteFile(idlPath, []byte(idl), 0o600)) + + outDir := t.TempDir() + _, err := GenerateBindingsTS(idlPath, "encodings", outDir) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(outDir, "Encodings.ts")) + require.NoError(t, err) + source := string(content) + + // Scalar filter fields with their subkey encoders. + assert.Contains(t, source, "small?: number | null") + assert.Contains(t, source, "amount?: bigint | null") + assert.Contains(t, source, "ratio?: number | null") + assert.Contains(t, source, "blob?: Uint8Array | null") + // Option is unwrapped for the filter value type. + assert.Contains(t, source, "maybeTag?: string | null") + assert.Contains(t, source, "value: bytesToBase64(prepareSubkeyValue(f.amount))") + assert.Contains(t, source, "value: bytesToBase64(prepareSubkeyFloatValue(f.ratio))") + assert.Contains(t, source, "prepareSubkeyFloatValue,") + // Subkey paths use the Go bindings' PascalCase names. + assert.Contains(t, source, "path: ['MaybeTag']") + // bool, u128, vec, and fixed arrays are not auto-filterable. + assert.NotContains(t, source, "flag?:") + assert.NotContains(t, source, "huge?:") + assert.NotContains(t, source, "list?:") + assert.NotContains(t, source, "fixed?:") +} + +func TestGenerateBindingsTS_FailLoud(t *testing.T) { + cases := []struct { + name string + typesJSON string + wantErr string + }{ + { + name: "u256", + typesJSON: `[{"name": "Big", "type": {"kind": "struct", "fields": [{"name": "x", "type": "u256"}]}}]`, + wantErr: "u256", + }, + { + name: "i256", + typesJSON: `[{"name": "Big", "type": {"kind": "struct", "fields": [{"name": "x", "type": "i256"}]}}]`, + wantErr: "i256", + }, + { + name: "coption", + typesJSON: `[{"name": "Opt", "type": {"kind": "struct", "fields": [{"name": "x", "type": {"coption": "u64"}}]}}]`, + wantErr: "COption", + }, + { + name: "data-carrying enum", + typesJSON: `[{"name": "Shape", "type": {"kind": "enum", "variants": [ + {"name": "Circle", "fields": [{"name": "radius", "type": "u64"}]}, + {"name": "Point"} + ]}}]`, + wantErr: "data-carrying enums", + }, + { + name: "tuple struct", + typesJSON: `[{"name": "Pair", "type": {"kind": "struct", "fields": ["u64", "string"]}}]`, + wantErr: "tuple struct fields", + }, + { + name: "cyclic types", + typesJSON: `[ + {"name": "A", "type": {"kind": "struct", "fields": [{"name": "b", "type": {"defined": {"name": "B"}}}]}}, + {"name": "B", "type": {"kind": "struct", "fields": [{"name": "a", "type": {"defined": {"name": "A"}}}]}} + ]`, + wantErr: "cyclic type reference", + }, + { + name: "field name collision after camelCase", + typesJSON: `[{"name": "Collide", "type": {"kind": "struct", "fields": [ + {"name": "user_data", "type": "u8"}, + {"name": "userData", "type": "u8"} + ]}}]`, + wantErr: "both map to", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + idlPath := writeTestIdl(t, tc.typesJSON) + _, err := GenerateBindingsTS(idlPath, "fail_loud", t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +// TestGenerateBindingsTS_MissingAddress verifies that an IDL without an address +// field succeeds (previously it returned an error). The generated program ID +// constant must be empty so callers know to supply the address at construction time. +func TestGenerateBindingsTS_MissingAddress(t *testing.T) { + idlPath := writeTestIdlNoAddress(t) + outDir := t.TempDir() + className, err := GenerateBindingsTS(idlPath, "no_addr", outDir) + require.NoError(t, err) + assert.Equal(t, "NoAddr", className) + + content, err := os.ReadFile(filepath.Join(outDir, "NoAddr.ts")) + require.NoError(t, err) + source := string(content) + + // Program ID constant must be empty — caller must supply it at construction time. + assert.Contains(t, source, "export const NO_ADDR_PROGRAM_ID = ''") +} + +func TestGenerateBindingsTS_WriteMethodCollision(t *testing.T) { + idlPath := writeTestIdl(t, `[ + {"name": "Foo", "type": {"kind": "struct", "fields": [{"name": "x", "type": "u8"}]}}, + {"name": "Foos", "type": {"kind": "struct", "fields": [{"name": "y", "type": "u8"}]}} + ]`) + _, err := GenerateBindingsTS(idlPath, "fail_loud", t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "write method name collision") +} diff --git a/cmd/root.go b/cmd/root.go index d0bcbccc..421e7ea1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/account" "github.com/smartcontractkit/cre-cli/cmd/client" "github.com/smartcontractkit/cre-cli/cmd/creinit" + executioncmd "github.com/smartcontractkit/cre-cli/cmd/execution" generatebindings "github.com/smartcontractkit/cre-cli/cmd/generate-bindings" "github.com/smartcontractkit/cre-cli/cmd/login" "github.com/smartcontractkit/cre-cli/cmd/logout" @@ -471,10 +472,16 @@ func newRootCommand() *cobra.Command { false, "Skip chain-name validation against the chain-selectors registry (for experimental chains)", ) + rootCmd.PersistentFlags().Bool( + settings.Flags.AllowInsecureRPC.Name, + false, + "Allow non-localhost HTTP RPC URLs (insecure)", + ) rootCmd.CompletionOptions.HiddenDefaultCmd = true secretsCmd := secrets.New(runtimeContext) workflowCmd := workflow.New(runtimeContext) + executionCmd := executioncmd.New(runtimeContext) versionCmd := version.New(runtimeContext) loginCmd := login.New(runtimeContext) logoutCmd := logout.New(runtimeContext) @@ -488,6 +495,7 @@ func newRootCommand() *cobra.Command { secretsCmd.RunE = helpRunE workflowCmd.RunE = helpRunE + executionCmd.RunE = helpRunE accountCmd.RunE = helpRunE templatesCmd.RunE = helpRunE registryCmd.RunE = helpRunE @@ -496,6 +504,7 @@ func newRootCommand() *cobra.Command { rootCmd.AddGroup(&cobra.Group{ID: "getting-started", Title: "Getting Started"}) rootCmd.AddGroup(&cobra.Group{ID: "account", Title: "Account"}) rootCmd.AddGroup(&cobra.Group{ID: "workflow", Title: "Workflow"}) + rootCmd.AddGroup(&cobra.Group{ID: "execution", Title: "Execution"}) rootCmd.AddGroup(&cobra.Group{ID: "secret", Title: "Secret"}) rootCmd.AddGroup(&cobra.Group{ID: "registry", Title: "Registry"}) @@ -509,6 +518,7 @@ func newRootCommand() *cobra.Command { secretsCmd.GroupID = "secret" workflowCmd.GroupID = "workflow" + executionCmd.GroupID = "execution" registryCmd.GroupID = "registry" rootCmd.AddCommand( @@ -520,6 +530,7 @@ func newRootCommand() *cobra.Command { whoamiCmd, secretsCmd, workflowCmd, + executionCmd, registryCmd, genBindingsCmd, updateCmd, @@ -555,6 +566,11 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre workflow limits export": {}, "cre workflow build": {}, "cre workflow list": {}, + "cre execution": {}, + "cre execution list": {}, + "cre execution status": {}, + "cre execution events": {}, + "cre execution logs": {}, "cre account": {}, "cre secrets": {}, "cre templates": {}, @@ -586,6 +602,7 @@ func isLoadCredentials(cmd *cobra.Command) bool { "cre generate-bindings solana": {}, "cre update": {}, "cre workflow": {}, + "cre execution": {}, "cre workflow limits": {}, "cre workflow limits export": {}, "cre account": {}, @@ -658,6 +675,7 @@ func shouldShowSpinner(cmd *cobra.Command) bool { "cre logout": {}, "cre update": {}, "cre workflow": {}, // Just shows help + "cre execution": {}, // Just shows help "cre workflow limits": {}, // Just shows help "cre workflow limits export": {}, // Static data, no project needed "cre account": {}, // Just shows help diff --git a/cmd/secrets/common/browser_flow.go b/cmd/secrets/common/browser_flow.go index f98fc166..a1053b50 100644 --- a/cmd/secrets/common/browser_flow.go +++ b/cmd/secrets/common/browser_flow.go @@ -178,10 +178,17 @@ func (h *Handler) ExecuteBrowserVaultAuthorization(ctx context.Context, method s return fmt.Errorf("could not complete the authorization request") } - platformState, _ := oauth.StateFromAuthorizeURL(authURL) + localState, err := oauth.RandomState() + if err != nil { + return err + } + authURL, err = oauth.AuthorizeURLWithState(authURL, localState) + if err != nil { + return fmt.Errorf("could not bind OAuth state: %w", err) + } codeCh := make(chan string, 1) - server, listener, err := oauth.NewCallbackHTTPServer(constants.AuthListenAddr, oauth.SecretsCallbackHandler(codeCh, platformState, h.Log)) + server, listener, err := oauth.NewCallbackHTTPServer(constants.AuthListenAddr, oauth.SecretsCallbackHandler(codeCh, localState, h.Log)) if err != nil { return fmt.Errorf("could not start local callback server: %w", err) } @@ -240,6 +247,11 @@ func (h *Handler) ExecuteBrowserVaultAuthorization(ctx context.Context, method s // postVaultGatewayWithBearer POSTs the digest-bound JSON-RPC body with the vault JWT and parses the gateway response. func (h *Handler) postVaultGatewayWithBearer(method string, requestBody []byte, accessToken string) error { + requestID, err := jsonRPCRequestID(requestBody) + if err != nil { + return err + } + ui.Dim("Submitting request to vault gateway...") respBody, status, err := h.Gw.PostWithBearer(requestBody, accessToken) if err != nil { @@ -248,5 +260,18 @@ func (h *Handler) postVaultGatewayWithBearer(method string, requestBody []byte, if status != http.StatusOK { return fmt.Errorf("gateway returned a non-200 status code: status_code=%d, body=%s", status, respBody) } - return h.ParseVaultGatewayResponse(method, respBody) + return h.ParseVaultGatewayResponse(method, requestID, respBody) +} + +func jsonRPCRequestID(body []byte) (string, error) { + var req struct { + ID string `json:"id"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "", fmt.Errorf("failed to parse jsonrpc request id: %w", err) + } + if req.ID == "" { + return "", fmt.Errorf("jsonrpc request id is empty") + } + return req.ID, nil } diff --git a/cmd/secrets/common/browser_flow_test.go b/cmd/secrets/common/browser_flow_test.go index 43e96392..be1b2439 100644 --- a/cmd/secrets/common/browser_flow_test.go +++ b/cmd/secrets/common/browser_flow_test.go @@ -97,7 +97,7 @@ func TestPostVaultGatewayWithBearer_ListParsesResponse(t *testing.T) { }, } - err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsList, []byte(`{}`), "t") + err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsList, []byte(`{"jsonrpc":"2.0","id":"1","method":"x"}`), "t") w.Close() os.Stdout = oldStdout var out strings.Builder @@ -115,7 +115,7 @@ func TestPostVaultGatewayWithBearer_GatewayNon200(t *testing.T) { }, } - err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsDelete, []byte(`{}`), "t") + err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsDelete, []byte(`{"jsonrpc":"2.0","id":"1","method":"x"}`), "t") require.Error(t, err) assert.Contains(t, err.Error(), "non-200") assert.Contains(t, err.Error(), "403") @@ -129,7 +129,7 @@ func TestPostVaultGatewayWithBearer_InvalidJSONRPC(t *testing.T) { }, } - err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsUpdate, []byte(`{}`), "t") + err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsUpdate, []byte(`{"jsonrpc":"2.0","id":"1","method":"x"}`), "t") require.Error(t, err) assert.Contains(t, err.Error(), "unmarshal") } diff --git a/cmd/secrets/common/gateway/url.go b/cmd/secrets/common/gateway/url.go new file mode 100644 index 00000000..0091730a --- /dev/null +++ b/cmd/secrets/common/gateway/url.go @@ -0,0 +1,44 @@ +package gateway + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// ValidateGatewayURL rejects vault gateway URLs that are not HTTPS with a host, +// except for HTTP on loopback hosts used by local development and integration tests. +func ValidateGatewayURL(raw string) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("invalid vault gateway URL: %w", err) + } + if u.Scheme == "" { + return fmt.Errorf("invalid vault gateway URL") + } + if u.Host == "" { + return fmt.Errorf("vault gateway URL is missing a host") + } + + host := u.Hostname() + switch u.Scheme { + case "https": + return nil + case "http": + if isLoopbackHost(host) { + return nil + } + return fmt.Errorf("vault gateway URL must use https://, got %q", u.Scheme) + default: + return fmt.Errorf("vault gateway URL must use https://, got %q", u.Scheme) + } +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/cmd/secrets/common/gateway/url_test.go b/cmd/secrets/common/gateway/url_test.go new file mode 100644 index 00000000..13ee197b --- /dev/null +++ b/cmd/secrets/common/gateway/url_test.go @@ -0,0 +1,35 @@ +package gateway + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateGatewayURL(t *testing.T) { + require.NoError(t, ValidateGatewayURL("https://gateway.example.com/")) + require.NoError(t, ValidateGatewayURL("https://gateway.example.com/v1")) + require.NoError(t, ValidateGatewayURL("http://127.0.0.1:57244")) + require.NoError(t, ValidateGatewayURL("http://localhost:8080")) + require.NoError(t, ValidateGatewayURL("http://[::1]:8080")) + + err := ValidateGatewayURL("http://gateway.example.com/") + require.Error(t, err) + require.Contains(t, err.Error(), "https://") + + err = ValidateGatewayURL("http://10.0.0.1/") + require.Error(t, err) + require.Contains(t, err.Error(), "https://") + + err = ValidateGatewayURL("http://127.0.0.1.evil.com/") + require.Error(t, err) + require.Contains(t, err.Error(), "https://") + + err = ValidateGatewayURL("https://") + require.Error(t, err) + require.Contains(t, err.Error(), "missing a host") + + err = ValidateGatewayURL("not-a-url") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid vault gateway URL") +} diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index 90c5cfbb..200c02db 100644 --- a/cmd/secrets/common/handler.go +++ b/cmd/secrets/common/handler.go @@ -20,7 +20,6 @@ import ( "github.com/machinebox/graphql" "github.com/rs/zerolog" "github.com/spf13/viper" - "google.golang.org/protobuf/encoding/protojson" "gopkg.in/yaml.v2" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" @@ -125,6 +124,9 @@ func NewHandler(execCtx context.Context, ctx *runtime.Context, secretsFilePath, execCtx: execCtx, } h.GatewayURL = gateway.ResolveVaultGatewayURL(ctx.TenantContext, ctx.EnvironmentSet) + if err := gateway.ValidateGatewayURL(h.GatewayURL); err != nil { + return nil, err + } h.Gw = &gateway.HTTPClient{URL: h.GatewayURL, Client: &http.Client{Timeout: 90 * time.Second}} if !IsBrowserFlow(secretsAuth) { @@ -148,7 +150,7 @@ func (h *Handler) CloseCapRegClient() { // EnsureDeploymentRPCForOwnerKeySecrets checks project settings for an RPC URL on the workflow registry chain (owner-key / allowlist flows only). func (h *Handler) EnsureDeploymentRPCForOwnerKeySecrets() error { - return settings.ValidateDeploymentRPC(&h.Settings.Workflow, h.EnvironmentSet.WorkflowRegistryChainName) + return settings.ValidateDeploymentRPC(&h.Settings.Workflow, h.EnvironmentSet.WorkflowRegistryChainName, h.Log, nil) } // ResolveInputs loads secrets from a YAML file. @@ -315,6 +317,63 @@ func (h *Handler) fetchVaultMasterPublicKeyHex() (string, error) { return rpcResp.Result.PublicKey, nil } +func (h *Handler) vaultPublicKeyFromCapReg(ctx context.Context) (string, error) { + v, err := h.vaultDONResolver.ResolveVaultDON(ctx) + if err != nil { + return "", fmt.Errorf("resolve vault DON: %w", err) + } + pubKeyHex, err := vaultdon.VaultPublicKeyHex(v) + if err != nil { + return "", fmt.Errorf("read vault public key from CapabilitiesRegistry: %w", err) + } + return pubKeyHex, nil +} + +// optionalCapRegVaultPublicKeyHex returns the on-chain vault public key when CapabilitiesRegistry +// RPC settings are available. compare is false only when no RPC is configured for the registry chain. +func (h *Handler) optionalCapRegVaultPublicKeyHex(ctx context.Context) (key string, compare bool, err error) { + if h.vaultDONResolver != nil { + key, err = h.vaultPublicKeyFromCapReg(ctx) + return key, true, err + } + + rpcURL, _, ok, err := settings.ResolveCapabilitiesRegistryRPC(h.Viper, h.TenantContext) + if err != nil { + return "", false, err + } + if !ok { + return "", false, nil + } + + if err = h.initVaultDONResolver(ctx, rpcURL); err != nil { + return "", true, err + } + + key, err = h.vaultPublicKeyFromCapReg(ctx) + return key, true, err +} + +// vaultMasterPublicKeyHex loads the vault master public key from the gateway and, when CapabilitiesRegistry +// RPC is configured, verifies it matches the on-chain commitment before encryption. +func (h *Handler) vaultMasterPublicKeyHex(ctx context.Context) (string, error) { + gatewayKey, err := h.fetchVaultMasterPublicKeyHex() + if err != nil { + return "", err + } + + onChainKey, compare, err := h.optionalCapRegVaultPublicKeyHex(ctx) + if err != nil { + return "", err + } + if !compare { + return gatewayKey, nil + } + if !strings.EqualFold(gatewayKey, onChainKey) { + return "", fmt.Errorf("vault public key from gateway does not match CapabilitiesRegistry") + } + return gatewayKey, nil +} + // ResolveEffectiveOwner returns the checksummed workflow owner address for owner-key vault operations. func (h *Handler) ResolveEffectiveOwner() (string, error) { if !common.IsHexAddress(h.OwnerAddress) { @@ -347,7 +406,7 @@ func (h *Handler) ResolveVaultIdentifierOwnerForAuth(secretsAuth string) (string // TDH2 label is the workflow owner address left-padded to 32 bytes; SecretIdentifier.Owner is the same hex address string. // Each item's Value slice is zeroed in place as it is encrypted; callers must not reuse rawSecrets afterward. func (h *Handler) EncryptSecrets(rawSecrets UpsertSecretsInputs, owner string) ([]*vault.EncryptedSecret, error) { - pubKeyHex, err := h.fetchVaultMasterPublicKeyHex() + pubKeyHex, err := h.vaultMasterPublicKeyHex(h.execCtx) if err != nil { return nil, err } @@ -545,7 +604,7 @@ func (h *Handler) Execute( if status != http.StatusOK { return fmt.Errorf("gateway returned a non-200 status code: status_code=%d, body=%s", status, respBody) } - return h.ParseVaultGatewayResponse(method, respBody) + return h.ParseVaultGatewayResponse(method, requestID, respBody) } if txOut == nil && allowlisted { @@ -626,10 +685,10 @@ func (h *Handler) Execute( return nil } -// ParseVaultGatewayResponse parses the JSON-RPC response, decodes the SignedOCRResponse payload -// into the appropriate proto type (CreateSecretsResponse, UpdateSecretsResponse, DeleteSecretsResponse), -// and logs one line per secret with id/owner/namespace/success/error. -func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) error { +// ParseVaultGatewayResponse parses the JSON-RPC response, optionally verifies OCR signatures +// and the JSON-RPC response id, decodes the SignedOCRResponse payload into the appropriate proto +// type, and logs one line per secret with id/owner/namespace/success/error. +func (h *Handler) ParseVaultGatewayResponse(method, requestID string, respBody []byte) error { // Unmarshal JSON-RPC envelope with SignedOCRResponse result var rpcResp jsonrpc2.Response[vaulttypes.SignedOCRResponse] if err := json.Unmarshal(respBody, &rpcResp); err != nil { @@ -642,6 +701,10 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro return fmt.Errorf("gateway returned JSON-RPC error: %s", string(b)) } + if err := h.verifyVaultGatewayResponse(h.execCtx, &rpcResp, requestID); err != nil { + return err + } + // Ensure we have a result payload if len(rpcResp.Result.Payload) == 0 { return fmt.Errorf("empty SignedOCRResponse payload") @@ -651,7 +714,7 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro switch method { case vaulttypes.MethodSecretsCreate: var p vault.CreateSecretsResponse - if err := protojson.Unmarshal(rpcResp.Result.Payload, &p); err != nil { + if err := unmarshalVaultResponsePayload(rpcResp.Result.Payload, &p); err != nil { return fmt.Errorf("failed to decode create payload: %w", err) } @@ -671,7 +734,7 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro } case vaulttypes.MethodSecretsUpdate: var p vault.UpdateSecretsResponse - if err := protojson.Unmarshal(rpcResp.Result.Payload, &p); err != nil { + if err := unmarshalVaultResponsePayload(rpcResp.Result.Payload, &p); err != nil { return fmt.Errorf("failed to decode update payload: %w", err) } for _, r := range p.GetResponses() { @@ -689,7 +752,7 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro } case vaulttypes.MethodSecretsDelete: var p vault.DeleteSecretsResponse - if err := protojson.Unmarshal(rpcResp.Result.Payload, &p); err != nil { + if err := unmarshalVaultResponsePayload(rpcResp.Result.Payload, &p); err != nil { return fmt.Errorf("failed to decode delete payload: %w", err) } for _, r := range p.GetResponses() { @@ -707,7 +770,7 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro } case vaulttypes.MethodSecretsList: var p vault.ListSecretIdentifiersResponse - if err := protojson.Unmarshal(rpcResp.Result.Payload, &p); err != nil { + if err := unmarshalVaultResponsePayload(rpcResp.Result.Payload, &p); err != nil { return fmt.Errorf("failed to decode list payload: %w", err) } diff --git a/cmd/secrets/common/handler_test.go b/cmd/secrets/common/handler_test.go index f6dcaf35..ffb2bf37 100644 --- a/cmd/secrets/common/handler_test.go +++ b/cmd/secrets/common/handler_test.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "math/big" "net/http" "strings" "testing" @@ -14,16 +15,19 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/rs/zerolog" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" + capreg "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/capabilities_registry_wrapper_v2" "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/workflow_registry_wrapper_v2" "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" "github.com/smartcontractkit/cre-cli/cmd/secrets/common/gateway" + "github.com/smartcontractkit/cre-cli/cmd/secrets/common/vaultdon" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" @@ -66,29 +70,46 @@ func TestZeroUpsertSecretValues(t *testing.T) { } } -func TestEncryptSecrets(t *testing.T) { - h, _, _ := newMockHandler(t) - h.OwnerAddress = "0xabc" +func attachGatewayPublicKeyMock(t *testing.T, h *Handler, publicKeyHex string) { + t.Helper() + h.Gw = &mockGatewayClient{ + post: func(body []byte) ([]byte, int, error) { + var req jsonrpc2.Request[vaultcommon.GetPublicKeyRequest] + require.NoError(t, json.Unmarshal(body, &req)) + require.Equal(t, jsonrpc2.JsonRpcVersion, req.Version) + require.Equal(t, vaulttypes.MethodPublicKeyGet, req.Method) + + resp := jsonrpc2.Response[vaultcommon.GetPublicKeyResponse]{ + Version: jsonrpc2.JsonRpcVersion, + ID: req.ID, + Method: vaulttypes.MethodPublicKeyGet, + Result: &vaultcommon.GetPublicKeyResponse{PublicKey: publicKeyHex}, + } + b, err := json.Marshal(resp) + return b, http.StatusOK, err + }, + } +} + +func attachCapRegTenantWithoutRPC(t *testing.T, h *Handler) { + t.Helper() + h.TenantContext = &tenantctx.EnvironmentContext{ + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: 16015286601757825753, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } + v := viper.New() + v.Set(settings.CreTargetEnvVar, "staging") + h.Viper = v +} +func TestEncryptSecrets(t *testing.T) { t.Run("success - encrypts secrets with a gateway-fetched public key", func(t *testing.T) { - h.Gw = &mockGatewayClient{ - post: func(body []byte) ([]byte, int, error) { - // Echo a valid JSON-RPC response with matching ID/method - var req jsonrpc2.Request[vaultcommon.GetPublicKeyRequest] - require.NoError(t, json.Unmarshal(body, &req)) - require.Equal(t, jsonrpc2.JsonRpcVersion, req.Version) - require.Equal(t, vaulttypes.MethodPublicKeyGet, req.Method) - - resp := jsonrpc2.Response[vaultcommon.GetPublicKeyResponse]{ - Version: jsonrpc2.JsonRpcVersion, - ID: req.ID, - Method: vaulttypes.MethodPublicKeyGet, - Result: &vaultcommon.GetPublicKeyResponse{PublicKey: vaultPublicKeyHex}, - } - b, _ := json.Marshal(resp) - return b, http.StatusOK, nil - }, - } + h, _, _ := newMockHandler(t) + h.OwnerAddress = "0xabc" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + attachCapRegTenantWithoutRPC(t, h) raw := UpsertSecretsInputs{ {ID: "test-secret-1", Value: []byte("value1"), Namespace: "ns1"}, @@ -107,7 +128,6 @@ func TestEncryptSecrets(t *testing.T) { require.Equal(t, "ns2", enc[1].Id.Namespace) require.Equal(t, "0xabc", enc[1].Id.Owner) - // We can't (and don't need to) decrypt here; just assert it's valid hex and non-empty. _, err = hex.DecodeString(enc[0].EncryptedValue) require.NoError(t, err) require.NotEmpty(t, enc[0].EncryptedValue) @@ -121,7 +141,32 @@ func TestEncryptSecrets(t *testing.T) { } }) + t.Run("success - gateway key matches CapabilitiesRegistry when RPC is configured", func(t *testing.T) { + h, _, _ := newMockHandler(t) + h.OwnerAddress = "0xabc" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + attachMockVaultDONResolver(t, h, vaultPublicKeyHex) + + enc, err := h.EncryptSecrets(UpsertSecretsInputs{{ID: "s", Value: []byte("v"), Namespace: "n"}}, "0xabc") + require.NoError(t, err) + require.Len(t, enc, 1) + }) + + t.Run("failure - gateway key does not match CapabilitiesRegistry", func(t *testing.T) { + h, _, _ := newMockHandler(t) + h.OwnerAddress = "0xabc" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + attachMockVaultDONResolver(t, h, "deadbeef") + + enc, err := h.EncryptSecrets(UpsertSecretsInputs{{ID: "s", Value: []byte("v"), Namespace: "n"}}, "0xabc") + require.Error(t, err) + require.Nil(t, enc) + require.Contains(t, err.Error(), "does not match CapabilitiesRegistry") + }) + t.Run("failure - gateway POST error", func(t *testing.T) { + h, _, _ := newMockHandler(t) + h.OwnerAddress = "0xabc" h.Gw = &mockGatewayClient{ post: func(_ []byte) ([]byte, int, error) { return nil, 0, errors.New("network down") @@ -135,6 +180,8 @@ func TestEncryptSecrets(t *testing.T) { }) t.Run("failure - JSON-RPC error from gateway", func(t *testing.T) { + h, _, _ := newMockHandler(t) + h.OwnerAddress = "0xabc" h.Gw = &mockGatewayClient{ post: func(body []byte) ([]byte, int, error) { var req jsonrpc2.Request[vaultcommon.GetPublicKeyRequest] @@ -159,6 +206,44 @@ func TestEncryptSecrets(t *testing.T) { require.Nil(t, enc) require.Contains(t, err.Error(), "vault public key fetch error") }) + + t.Run("failure - invalid CapabilitiesRegistry RPC configuration", func(t *testing.T) { + h, _, _ := newMockHandler(t) + h.OwnerAddress = "0xabc" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + h.TenantContext = &tenantctx.EnvironmentContext{ + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: 16015286601757825753, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } + 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"}, + }) + h.Viper = v + + enc, err := h.EncryptSecrets(UpsertSecretsInputs{{ID: "s", Value: []byte("v"), Namespace: "n"}}, "0xabc") + require.Error(t, err) + require.Nil(t, enc) + require.Contains(t, err.Error(), "invalid RPC URL") + }) + + t.Run("failure - vault DON resolution error when RPC is configured", func(t *testing.T) { + h, _, _ := newMockHandler(t) + h.OwnerAddress = "0xabc" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + h.vaultDONResolver = vaultdon.NewResolver(&mockCapRegReader{ + donIDs: []*big.Int{big.NewInt(1)}, + dons: map[uint32]capreg.CapabilitiesRegistryDONInfo{}, + }, "zone-a") + + enc, err := h.EncryptSecrets(UpsertSecretsInputs{{ID: "s", Value: []byte("v"), Namespace: "n"}}, "0xabc") + require.Error(t, err) + require.Nil(t, enc) + require.Contains(t, err.Error(), "resolve vault DON") + }) } func TestResolveEffectiveOwner(t *testing.T) { @@ -233,24 +318,10 @@ func TestResolveVaultIdentifierOwnerForAuth(t *testing.T) { } func TestEncryptSecrets_UsesWorkflowOwnerAddress(t *testing.T) { - mockGw := &mockGatewayClient{ - post: func(body []byte) ([]byte, int, error) { - var req jsonrpc2.Request[vaultcommon.GetPublicKeyRequest] - _ = json.Unmarshal(body, &req) - resp := jsonrpc2.Response[vaultcommon.GetPublicKeyResponse]{ - Version: jsonrpc2.JsonRpcVersion, - ID: req.ID, - Method: vaulttypes.MethodPublicKeyGet, - Result: &vaultcommon.GetPublicKeyResponse{PublicKey: vaultPublicKeyHex}, - } - b, _ := json.Marshal(resp) - return b, http.StatusOK, nil - }, - } - h, _, _ := newMockHandler(t) - h.Gw = mockGw h.OwnerAddress = "0xabc" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + attachCapRegTenantWithoutRPC(t, h) enc, err := h.EncryptSecrets(UpsertSecretsInputs{ {ID: "secret-1", Value: []byte("val1"), Namespace: "main"}, @@ -404,4 +475,22 @@ func TestNewHandler_GatewayURL(t *testing.T) { require.True(t, ok) require.Equal(t, "https://env-override.example.com/", gw.URL) }) + + t.Run("rejects non-https remote gateway URL", func(t *testing.T) { + t.Setenv(environments.EnvVarVaultGatewayURL, "") + ctx := *baseCtx + ctx.TenantContext = &tenantctx.EnvironmentContext{VaultGatewayURL: "http://insecure.example.com/"} + _, err := NewHandler(context.Background(), &ctx, "", SecretsAuthBrowser) + require.Error(t, err) + require.Contains(t, err.Error(), "https://") + }) + + t.Run("allows http loopback gateway URL", func(t *testing.T) { + t.Setenv(environments.EnvVarVaultGatewayURL, "") + ctx := *baseCtx + ctx.TenantContext = &tenantctx.EnvironmentContext{VaultGatewayURL: "http://127.0.0.1:1234"} + h, err := NewHandler(context.Background(), &ctx, "", SecretsAuthBrowser) + require.NoError(t, err) + require.Equal(t, "http://127.0.0.1:1234", h.GatewayURL) + }) } diff --git a/cmd/secrets/common/parse_response_test.go b/cmd/secrets/common/parse_response_test.go index 46f448c6..6ed1b4cb 100644 --- a/cmd/secrets/common/parse_response_test.go +++ b/cmd/secrets/common/parse_response_test.go @@ -2,6 +2,7 @@ package common import ( "bytes" + "context" "encoding/json" "io" "os" @@ -41,7 +42,11 @@ func encodeRPCBodyFromPayload(payload []byte) []byte { func newTestHandler(buf *bytes.Buffer) *Handler { logger := zerolog.New(buf) - return &Handler{Log: &logger} + return &Handler{ + Log: &logger, + execCtx: context.Background(), + skipVaultValidation: true, + } } // Build the payload using the real proto types @@ -129,7 +134,7 @@ func TestParseVaultGatewayResponse_Create_LogsPerItem(t *testing.T) { h := newTestHandler(&buf) body := encodeRPCBodyFromPayload(buildCreatePayloadProto(t)) - if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, body); err != nil { + if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, "", body); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -174,7 +179,7 @@ func TestParseVaultGatewayResponse_Update_Success(t *testing.T) { h := newTestHandler(nil) body := encodeRPCBodyFromPayload(buildUpdatePayloadProto(t)) - if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsUpdate, body); err != nil { + if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsUpdate, "", body); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -203,7 +208,7 @@ func TestParseVaultGatewayResponse_Delete_Success(t *testing.T) { h := newTestHandler(&buf) body := encodeRPCBodyFromPayload(buildDeletePayloadProto(t)) - if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsDelete, body); err != nil { + if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsDelete, "", body); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -226,7 +231,7 @@ func TestParseVaultGatewayResponse_JSONRPCError(t *testing.T) { h := newTestHandler(&buf) body := encodeRPCBodyFromError(-32000, "upstream failed") - err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, body) + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, "", body) if err == nil || !strings.Contains(err.Error(), "gateway returned JSON-RPC error") || !strings.Contains(err.Error(), "upstream failed") { t.Fatalf("expected JSON-RPC error surfaced, got: %v", err) @@ -239,7 +244,7 @@ func TestParseVaultGatewayResponse_EmptyPayload(t *testing.T) { // Omit payload entirely -> handler should report "empty SignedOCRResponse payload" raw := encodeRPCBodyFromPayload(nil) - err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsUpdate, raw) + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsUpdate, "", raw) if err == nil || !strings.Contains(err.Error(), "empty SignedOCRResponse payload") { t.Fatalf("expected empty payload error, got: %v", err) } @@ -250,7 +255,7 @@ func TestParseVaultGatewayResponse_MalformedTopLevelJSON(t *testing.T) { h := newTestHandler(&buf) raw := []byte(`{"jsonrpc":"2.0","id":"1","result": this is not valid}`) - err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsUpdate, raw) + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsUpdate, "", raw) if err == nil || !strings.Contains(err.Error(), "failed to unmarshal JSON-RPC response") { t.Fatalf("expected unmarshal error, got: %v", err) } @@ -262,7 +267,7 @@ func TestParseVaultGatewayResponse_BadPayloadForCreate(t *testing.T) { // Wrong shape for the proto: responses should be an array. raw := encodeRPCBodyFromPayload([]byte(`{"responses":"not-an-array"}`)) - err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, raw) + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, "", raw) if err == nil || !strings.Contains(err.Error(), "failed to decode create payload") { t.Fatalf("expected proto decode error for create, got: %v", err) } @@ -274,7 +279,7 @@ func TestParseVaultGatewayResponse_UnsupportedMethod_Warns(t *testing.T) { // Non-empty payload so it passes "empty payload" check; method is unknown -> warn. raw := encodeRPCBodyFromPayload([]byte(`{"anything":"ok"}`)) - if err := h.ParseVaultGatewayResponse("totally.unknown.method", raw); err != nil { + if err := h.ParseVaultGatewayResponse("totally.unknown.method", "", raw); err != nil { t.Fatalf("unexpected error: %v", err) } out := buf.String() @@ -337,7 +342,7 @@ func TestParseVaultGatewayResponse_List_SuccessWithIdentifiers(t *testing.T) { h := newTestHandler(&buf) body := encodeRPCBodyFromPayload(buildListPayloadProtoSuccessWithItems(t)) - if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, body); err != nil { + if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, "", body); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -369,7 +374,7 @@ func TestParseVaultGatewayResponse_List_EmptySuccess(t *testing.T) { h := newTestHandler(&buf) body := encodeRPCBodyFromPayload(buildListPayloadProtoEmptySuccess(t)) - if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, body); err != nil { + if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, "", body); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -398,7 +403,7 @@ func TestParseVaultGatewayResponse_List_Failure(t *testing.T) { h := newTestHandler(&buf) body := encodeRPCBodyFromPayload(buildListPayloadProtoFailure(t, "boom")) - if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, body); err != nil { + if err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, "", body); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -425,7 +430,7 @@ func TestParseVaultGatewayResponse_BadPayloadForList(t *testing.T) { // Wrong shape: identifiers should be an array raw := encodeRPCBodyFromPayload([]byte(`{"identifiers":"not-an-array"}`)) - err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, raw) + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, "", raw) if err == nil || !strings.Contains(err.Error(), "failed to decode list payload") { t.Fatalf("expected decode error for list payload, got: %v", err) } diff --git a/cmd/secrets/common/test_helpers.go b/cmd/secrets/common/test_helpers.go index b79ddf55..a059c2e3 100644 --- a/cmd/secrets/common/test_helpers.go +++ b/cmd/secrets/common/test_helpers.go @@ -30,6 +30,7 @@ func newMockHandler(t *testing.T) (*Handler, *MockClientFactory, *ecdsa.PrivateK OwnerAddress: "0xabc", EnvironmentSet: &environments.EnvironmentSet{}, Credentials: &credentials.Credentials{}, + execCtx: context.Background(), } return h, mockClientFactory, privateKey } diff --git a/cmd/secrets/common/test_helpers_capreg.go b/cmd/secrets/common/test_helpers_capreg.go new file mode 100644 index 00000000..01ecea6a --- /dev/null +++ b/cmd/secrets/common/test_helpers_capreg.go @@ -0,0 +1,73 @@ +package common + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" + capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" + capreg "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/capabilities_registry_wrapper_v2" + "github.com/smartcontractkit/chainlink-protos/cre/go/values" + + "github.com/smartcontractkit/cre-cli/cmd/secrets/common/vaultdon" +) + +type mockCapRegReader struct { + donIDs []*big.Int + dons map[uint32]capreg.CapabilitiesRegistryDONInfo + nodes []capreg.INodeInfoProviderNodeInfo +} + +func (m *mockCapRegReader) GetDONsInFamily(_ context.Context, _ string) ([]*big.Int, error) { + return m.donIDs, nil +} + +func (m *mockCapRegReader) GetDON(_ context.Context, donID uint32) (capreg.CapabilitiesRegistryDONInfo, error) { + don, ok := m.dons[donID] + if !ok { + return capreg.CapabilitiesRegistryDONInfo{}, fmt.Errorf("DON %d not found", donID) + } + return don, nil +} + +func (m *mockCapRegReader) GetNodes(_ context.Context) ([]capreg.INodeInfoProviderNodeInfo, error) { + return m.nodes, nil +} + +func attachMockVaultDONResolver(t *testing.T, h *Handler, vaultPublicKeyHex string) { + t.Helper() + + valueMap, err := values.WrapMap(map[string]any{ + "VaultPublicKey": vaultPublicKeyHex, + "Threshold": 1, + }) + require.NoError(t, err) + cfgBytes, err := proto.Marshal(&capabilitiespb.CapabilityConfig{ + DefaultConfig: values.ProtoMap(valueMap), + }) + require.NoError(t, err) + + p2pID := [32]byte{1} + reader := &mockCapRegReader{ + donIDs: []*big.Int{big.NewInt(1)}, + dons: map[uint32]capreg.CapabilitiesRegistryDONInfo{ + 1: { + Id: 1, + F: 0, + NodeP2PIds: [][32]byte{p2pID}, + CapabilityConfigurations: []capreg.CapabilitiesRegistryCapabilityConfiguration{ + {CapabilityId: vaultcommon.CapabilityID, Config: cfgBytes}, + }, + }, + }, + nodes: []capreg.INodeInfoProviderNodeInfo{{P2pId: p2pID}}, + } + + h.vaultDONResolver = vaultdon.NewResolver(reader, "zone-a") + h.skipVaultValidation = false +} diff --git a/cmd/secrets/common/vault_response_decode.go b/cmd/secrets/common/vault_response_decode.go new file mode 100644 index 00000000..1433ccb8 --- /dev/null +++ b/cmd/secrets/common/vault_response_decode.go @@ -0,0 +1,14 @@ +package common + +import ( + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// vaultResponseUnmarshal ignores unknown proto fields so newer gateway payloads +// remain decodable when the CLI's chainlink-common pin lags the vault DON. +var vaultResponseUnmarshal = protojson.UnmarshalOptions{DiscardUnknown: true} + +func unmarshalVaultResponsePayload(data []byte, msg proto.Message) error { + return vaultResponseUnmarshal.Unmarshal(data, msg) +} diff --git a/cmd/secrets/common/vault_response_decode_test.go b/cmd/secrets/common/vault_response_decode_test.go new file mode 100644 index 00000000..35efc131 --- /dev/null +++ b/cmd/secrets/common/vault_response_decode_test.go @@ -0,0 +1,19 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" +) + +func TestUnmarshalVaultResponsePayload_DiscardUnknownFields(t *testing.T) { + payload := []byte(`{"requestId":"req-123","responses":[{"id":{"key":"apiKey","owner":"0xabc","namespace":"main"},"success":true}]}`) + + var p vault.CreateSecretsResponse + require.NoError(t, unmarshalVaultResponsePayload(payload, &p)) + require.Len(t, p.GetResponses(), 1) + require.True(t, p.GetResponses()[0].GetSuccess()) + require.Equal(t, "apiKey", p.GetResponses()[0].GetId().GetKey()) +} diff --git a/cmd/secrets/common/vault_response_verify.go b/cmd/secrets/common/vault_response_verify.go new file mode 100644 index 00000000..8ab532c5 --- /dev/null +++ b/cmd/secrets/common/vault_response_verify.go @@ -0,0 +1,53 @@ +package common + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + + "github.com/smartcontractkit/cre-cli/internal/onchain/capabilitiesregistry" +) + +func (h *Handler) verifyVaultGatewayResponse( + ctx context.Context, + rpcResp *jsonrpc2.Response[vaulttypes.SignedOCRResponse], + requestID string, +) error { + if h.SkipVaultValidation() { + return nil + } + if requestID == "" { + return fmt.Errorf("missing request ID for vault response verification") + } + if rpcResp.ID != requestID { + return fmt.Errorf("jsonrpc id mismatch: got %q want %q", rpcResp.ID, requestID) + } + + resolver, ok := h.VaultDONResolver() + if !ok { + return nil + } + + signed := rpcResp.Result + if signed == nil { + return fmt.Errorf("empty SignedOCRResponse result") + } + // TODO(DEVSVCS-5365) + if len(signed.Signatures) == 0 { + return nil + } + + v, err := resolver.ResolveVaultDON(ctx) + if err != nil { + return fmt.Errorf("resolve vault DON for signature verification: %w", err) + } + + signers := capabilitiesregistry.OCRSignerAddresses(v.Nodes) + minSigs := capabilitiesregistry.MinOCRSignatures(v.DON.F) + if err := vaulttypes.ValidateSignatures(signed, signers, minSigs); err != nil { + return fmt.Errorf("vault response signature verification failed: %w", err) + } + return nil +} diff --git a/cmd/secrets/common/vault_response_verify_test.go b/cmd/secrets/common/vault_response_verify_test.go new file mode 100644 index 00000000..bd423963 --- /dev/null +++ b/cmd/secrets/common/vault_response_verify_test.go @@ -0,0 +1,195 @@ +package common + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" + capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" + capreg "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/capabilities_registry_wrapper_v2" + "github.com/smartcontractkit/chainlink-protos/cre/go/values" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + + "github.com/smartcontractkit/cre-cli/cmd/secrets/common/vaultdon" +) + +func attachMockVaultDONResolverWithOCRSigners(t *testing.T, h *Handler, signers []common.Address) { + t.Helper() + + valueMap, err := values.WrapMap(map[string]any{ + "VaultPublicKey": "deadbeef", + "Threshold": 1, + }) + require.NoError(t, err) + cfgBytes, err := proto.Marshal(&capabilitiespb.CapabilityConfig{ + DefaultConfig: values.ProtoMap(valueMap), + }) + require.NoError(t, err) + + nodes := make([]capreg.INodeInfoProviderNodeInfo, len(signers)) + for i, addr := range signers { + p2pID := [32]byte{byte(i + 1)} + copy(nodes[i].Signer[:], addr.Bytes()) + nodes[i].P2pId = p2pID + } + + reader := &mockCapRegReader{ + donIDs: []*big.Int{big.NewInt(1)}, + dons: map[uint32]capreg.CapabilitiesRegistryDONInfo{ + 1: { + Id: 1, + F: 1, + NodeP2PIds: func() [][32]byte { + ids := make([][32]byte, len(nodes)) + for i := range nodes { + ids[i] = nodes[i].P2pId + } + return ids + }(), + CapabilityConfigurations: []capreg.CapabilitiesRegistryCapabilityConfiguration{ + {CapabilityId: vaultcommon.CapabilityID, Config: cfgBytes}, + }, + }, + }, + nodes: nodes, + } + + h.vaultDONResolver = vaultdon.NewResolver(reader, "zone-a") + h.skipVaultValidation = false +} + +func encodeSignedRPCBody(t *testing.T, requestID string, payload []byte, ctxHex string, sigs ...string) []byte { + t.Helper() + + ctxBytes, err := hex.DecodeString(ctxHex) + require.NoError(t, err) + + signatures := make([][]byte, 0, len(sigs)) + for _, sigHex := range sigs { + sig, err := hex.DecodeString(sigHex) + require.NoError(t, err) + signatures = append(signatures, sig) + } + + result := map[string]any{ + "payload": json.RawMessage(payload), + "context": ctxBytes, + "signatures": signatures, + } + resultJSON, err := json.Marshal(result) + require.NoError(t, err) + + resp := map[string]any{ + "jsonrpc": "2.0", + "id": requestID, + "result": json.RawMessage(resultJSON), + } + body, err := json.Marshal(resp) + require.NoError(t, err) + return body +} + +func TestVerifyVaultGatewayResponse_SkipWhenOptedOut(t *testing.T) { + var buf bytes.Buffer + logger := zerolog.New(&buf) + h := &Handler{Log: &logger, execCtx: context.Background(), skipVaultValidation: true} + + body := encodeSignedRPCBody(t, "req-1", []byte(`{"responses":[]}`), + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "d1067844e2849b404d903730c4cae19f090d53a578a1e8dc16ecbdc0285c1f186599108abbe0073b78bc148a6504907474ed3a6881df917e6d142cff70acfb5900", + ) + + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, "other-id", body) + require.NoError(t, err) +} + +func TestVerifyVaultGatewayResponse_RequestIDMismatch(t *testing.T) { + var buf bytes.Buffer + logger := zerolog.New(&buf) + h := &Handler{Log: &logger, execCtx: context.Background()} + + body := encodeRPCBodyFromPayload(buildCreatePayloadProto(t)) + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, "expected-id", body) + require.ErrorContains(t, err, "jsonrpc id mismatch") +} + +func TestVerifyVaultGatewayResponse_EmptySignaturesPassThrough(t *testing.T) { + var buf bytes.Buffer + logger := zerolog.New(&buf) + h := &Handler{Log: &logger, execCtx: context.Background()} + attachMockVaultDONResolver(t, h, "deadbeef") + + requestID := "req-empty-sigs" + body := encodeRPCBodyFromPayload(buildCreatePayloadProto(t)) + var resp testRPCResp + require.NoError(t, json.Unmarshal(body, &resp)) + resp.ID = requestID + body, err := json.Marshal(resp) + require.NoError(t, err) + + err = h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, requestID, body) + require.NoError(t, err) +} + +func TestVerifyVaultGatewayResponse_ValidSignatures(t *testing.T) { + var buf bytes.Buffer + logger := zerolog.New(&buf) + h := &Handler{Log: &logger, execCtx: context.Background()} + attachMockVaultDONResolverWithOCRSigners(t, h, []common.Address{ + common.HexToAddress("0xd6da96fe596705b32bc3a0e11cdefad77feaad79"), + common.HexToAddress("0x327aa349c9718cd36c877d1e90458fe1929768ad"), + common.HexToAddress("0xe9bf394856d73402b30e160d0e05c847796f0e29"), + common.HexToAddress("0xefd5bdb6c3256f04489a6ca32654d547297f48b9"), + }) + + requestID := "req-valid-sigs" + payload := []byte(`{"responses":[{"error":"failed to verify ciphertext: cannot unmarshal data: unexpected end of JSON input","id":{"key":"W","namespace":"","owner":"foo"},"success":false}]}`) + body := encodeSignedRPCBody(t, requestID, payload, + "000ec4f6a2ba011e909eccf64628855b848e08876a1edd938a1372a9e51adff100000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000", + "d1067844e2849b404d903730c4cae19f090d53a578a1e8dc16ecbdc0285c1f186599108abbe0073b78bc148a6504907474ed3a6881df917e6d142cff70acfb5900", + "c7517c188d297093a6f602046fad7feafe19454ee9dc269b19c8e6c01268037d1f7b423eeecbc495dd2d9a65e106bc3eab849ddfd74a10cbd4ad50c7d953bd4b01", + ) + + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, requestID, body) + require.NoError(t, err) +} + +func TestVerifyVaultGatewayResponse_InvalidSignatures(t *testing.T) { + var buf bytes.Buffer + logger := zerolog.New(&buf) + h := &Handler{Log: &logger, execCtx: context.Background()} + attachMockVaultDONResolverWithOCRSigners(t, h, []common.Address{ + common.HexToAddress("0xd6da96fe596705b32bc3a0e11cdefad77feaad79"), + }) + + requestID := "req-invalid-sigs" + payload := []byte(`{"responses":[{"error":"failed to verify ciphertext: cannot unmarshal data: unexpected end of JSON input","id":{"key":"W","namespace":"","owner":"foo"},"success":false}]}`) + body := encodeSignedRPCBody(t, requestID, payload, + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "d1067844e2849b404d903730c4cae19f090d53a578a1e8dc16ecbdc0285c1f186599108abbe0073b78bc148a6504907474ed3a6881df917e6d142cff70acfb5900", + "c7517c188d297093a6f602046fad7feafe19454ee9dc269b19c8e6c01268037d1f7b423eeecbc495dd2d9a65e106bc3eab849ddfd74a10cbd4ad50c7d953bd4b01", + ) + + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsCreate, requestID, body) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "signature verification failed")) +} + +func TestJSONRPCRequestID(t *testing.T) { + id, err := jsonRPCRequestID([]byte(`{"jsonrpc":"2.0","id":"abc-123","method":"vault.secrets.list"}`)) + require.NoError(t, err) + require.Equal(t, "abc-123", id) + + _, err = jsonRPCRequestID([]byte(`{"jsonrpc":"2.0","method":"vault.secrets.list"}`)) + require.ErrorContains(t, err, "jsonrpc request id is empty") +} diff --git a/cmd/secrets/common/vault_validation.go b/cmd/secrets/common/vault_validation.go index 6e25ff06..e8cf9b8d 100644 --- a/cmd/secrets/common/vault_validation.go +++ b/cmd/secrets/common/vault_validation.go @@ -13,26 +13,15 @@ import ( const vaultValidationSkippedWarning = "Vault gateway validation skipped; the encryption key and response signatures will not be verified independently of the gateway." -// vaultValidationGateEnabled toggles CapabilitiesRegistry RPC resolution and consent -// before secrets commands. -const vaultValidationGateEnabled = false - -// vaultValidationGateEnabled toggles CapabilitiesRegistry RPC resolution and consent -// before secrets commands. // 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) { if h.vaultValidationDecided { return h.skipVaultValidation, nil } - if !vaultValidationGateEnabled { - h.skipVaultValidation = true - h.vaultValidationDecided = true - return true, nil - } - rpcURL, chainName, ok, err := settings.ResolveCapabilitiesRegistryRPC(h.Viper, h.TenantContext) if err != nil { return false, err diff --git a/cmd/secrets/common/vault_validation_test.go b/cmd/secrets/common/vault_validation_test.go index 8019706b..bf3cb549 100644 --- a/cmd/secrets/common/vault_validation_test.go +++ b/cmd/secrets/common/vault_validation_test.go @@ -22,24 +22,7 @@ func testHandlerWithCapReg(t *testing.T, v *viper.Viper, tenantCtx *tenantctx.En return h } -func TestEnsureVaultValidationOrConsent_GateDisabled(t *testing.T) { - if vaultValidationGateEnabled { - t.Skip("gate enabled; covered by other tests") - } - - h := testHandlerWithCapReg(t, viper.New(), &tenantctx.EnvironmentContext{}) - 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_RPCConfigured(t *testing.T) { - if !vaultValidationGateEnabled { - t.Skip("vault validation gate disabled") - } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { ID json.RawMessage `json:"id"` @@ -92,9 +75,6 @@ func TestEnsureVaultValidationOrConsent_RPCConfigured(t *testing.T) { } func TestEnsureVaultValidationOrConsent_SkipConfirmationWithoutRPC(t *testing.T) { - if !vaultValidationGateEnabled { - t.Skip("vault validation gate disabled") - } v := viper.New() v.Set(settings.CreTargetEnvVar, "staging") v.Set(settings.Flags.SkipConfirmation.Name, true) @@ -117,9 +97,6 @@ func TestEnsureVaultValidationOrConsent_SkipConfirmationWithoutRPC(t *testing.T) } func TestEnsureVaultValidationOrConsent_NonInteractiveWithoutRPC(t *testing.T) { - if !vaultValidationGateEnabled { - t.Skip("vault validation gate disabled") - } v := viper.New() v.Set(settings.CreTargetEnvVar, "staging") v.Set(settings.Flags.NonInteractive.Name, true) @@ -139,9 +116,6 @@ func TestEnsureVaultValidationOrConsent_NonInteractiveWithoutRPC(t *testing.T) { } func TestEnsureVaultValidationOrConsent_MissingDonFamily(t *testing.T) { - if !vaultValidationGateEnabled { - t.Skip("vault validation gate disabled") - } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { ID json.RawMessage `json:"id"` @@ -177,9 +151,6 @@ func TestEnsureVaultValidationOrConsent_MissingDonFamily(t *testing.T) { } func TestEnsureVaultValidationOrConsent_MissingCapabilitiesRegistry(t *testing.T) { - if !vaultValidationGateEnabled { - t.Skip("vault validation gate disabled") - } v := viper.New() h := testHandlerWithCapReg(t, v, &tenantctx.EnvironmentContext{}) diff --git a/cmd/secrets/delete/delete.go b/cmd/secrets/delete/delete.go index b067c0d9..25724b4f 100644 --- a/cmd/secrets/delete/delete.go +++ b/cmd/secrets/delete/delete.go @@ -188,7 +188,7 @@ func Execute(ctx context.Context, h *common.Handler, inputs DeleteSecretsInputs, if status != http.StatusOK { return fmt.Errorf("gateway returned a non-200 status code: status_code=%d, body=%s", status, respBody) } - return h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsDelete, respBody) + return h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsDelete, requestID, respBody) } ownerAddr := ethcommon.HexToAddress(owner) diff --git a/cmd/secrets/execute/execute.go b/cmd/secrets/execute/execute.go index 83e5ccc8..6648048a 100644 --- a/cmd/secrets/execute/execute.go +++ b/cmd/secrets/execute/execute.go @@ -94,7 +94,7 @@ func New(ctx *runtime.Context) *cobra.Command { } // Parse & print results according to the bundle method - return h.ParseVaultGatewayResponse(b.Method, respBody) + return h.ParseVaultGatewayResponse(b.Method, b.RequestID, respBody) }, } diff --git a/cmd/secrets/list/list.go b/cmd/secrets/list/list.go index a5c0891b..6796ab9a 100644 --- a/cmd/secrets/list/list.go +++ b/cmd/secrets/list/list.go @@ -165,7 +165,7 @@ func Execute(ctx context.Context, h *common.Handler, namespace string, duration if status != http.StatusOK { return fmt.Errorf("gateway returned a non-200 status code: status_code=%d, body=%s", status, respBody) } - return h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, respBody) + return h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, requestID, respBody) } if txOut == nil && allowlisted { diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go index c451fd48..e4624799 100644 --- a/cmd/secrets/secrets.go +++ b/cmd/secrets/secrets.go @@ -32,8 +32,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { "Timeout for secrets operations (e.g. 30m, 2h, 48h).", ) - secretsCmd.PersistentFlags().String("secrets-auth", "onchain", "Authentication mode: onchain (workflow owner key / registry) or browser (organization sign-in).") - _ = secretsCmd.PersistentFlags().MarkHidden("secrets-auth") + secretsCmd.PersistentFlags().String("secrets-auth", "onchain", "Authentication mode: onchain uses a wallet key for secrets on the on-chain registry; browser uses account credentials for secrets on the private registry.") secretsCmd.AddCommand(create.New(runtimeContext)) secretsCmd.AddCommand(update.New(runtimeContext)) diff --git a/cmd/update/linux_asset_suffix_linux.go b/cmd/update/linux_asset_suffix_linux.go new file mode 100644 index 00000000..9da6c62f --- /dev/null +++ b/cmd/update/linux_asset_suffix_linux.go @@ -0,0 +1,61 @@ +//go:build linux + +package update + +import ( + "fmt" + "os/exec" + "regexp" + "strings" + + "github.com/Masterminds/semver/v3" +) + +const ( + linuxLdd235Suffix = "_ldd2-35" + linuxGlibcThreshold = "2.36" +) + +var glibcVersionPattern = regexp.MustCompile(`(\d+\.\d+)(?:\.\d+)?`) + +func linuxAssetSuffix() string { + out, err := exec.Command("ldd", "--version").Output() // #nosec G204 -- fixed args, standard glibc probe + if err != nil { + return "" + } + + version, err := parseGlibcVersionFromLddOutput(string(out)) + if err != nil { + return "" + } + + threshold, err := semver.NewVersion(linuxGlibcThreshold) + if err != nil { + return "" + } + if version.LessThan(threshold) { + return linuxLdd235Suffix + } + return "" +} + +func parseGlibcVersionFromLddOutput(output string) (*semver.Version, error) { + firstLine := strings.TrimSpace(output) + if idx := strings.IndexByte(firstLine, '\n'); idx >= 0 { + firstLine = firstLine[:idx] + } + if firstLine == "" { + return nil, fmt.Errorf("empty ldd --version output") + } + + matches := glibcVersionPattern.FindAllString(firstLine, -1) + if len(matches) == 0 { + return nil, fmt.Errorf("could not parse glibc version from %q", firstLine) + } + + version, err := semver.NewVersion(matches[len(matches)-1]) + if err != nil { + return nil, fmt.Errorf("parse glibc version %q: %w", matches[len(matches)-1], err) + } + return version, nil +} diff --git a/cmd/update/linux_asset_suffix_linux_test.go b/cmd/update/linux_asset_suffix_linux_test.go new file mode 100644 index 00000000..e1c9edcd --- /dev/null +++ b/cmd/update/linux_asset_suffix_linux_test.go @@ -0,0 +1,89 @@ +//go:build linux + +package update + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" +) + +func TestParseGlibcVersionFromLddOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output string + want string + wantErr bool + }{ + { + name: "ubuntu 22.04", + output: "ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35\nCopyright (C) 2022 Free Software Foundation, Inc.\n", + want: "2.35", + }, + { + name: "ubuntu 24.04", + output: "ldd (Ubuntu GLIBC 2.39-0ubuntu8.4) 2.39\n", + want: "2.39", + }, + { + name: "rhel style", + output: "ldd (GNU libc) 2.34\n", + want: "2.34", + }, + { + name: "empty", + output: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseGlibcVersionFromLddOutput(tt.output) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + want, err := semver.NewVersion(tt.want) + require.NoError(t, err) + require.True(t, got.Equal(want)) + }) + } +} + +func TestLinuxAssetSuffixFromGlibcVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + output string + want string + }{ + { + output: "ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35\n", + want: linuxLdd235Suffix, + }, + { + output: "ldd (Ubuntu GLIBC 2.39-0ubuntu8.4) 2.39\n", + want: "", + }, + } + + threshold, err := semver.NewVersion(linuxGlibcThreshold) + require.NoError(t, err) + + for _, tt := range tests { + version, err := parseGlibcVersionFromLddOutput(tt.output) + require.NoError(t, err) + + suffix := "" + if version.LessThan(threshold) { + suffix = linuxLdd235Suffix + } + require.Equal(t, tt.want, suffix) + } +} diff --git a/cmd/update/linux_asset_suffix_stub.go b/cmd/update/linux_asset_suffix_stub.go new file mode 100644 index 00000000..71682f6f --- /dev/null +++ b/cmd/update/linux_asset_suffix_stub.go @@ -0,0 +1,7 @@ +//go:build !linux + +package update + +func linuxAssetSuffix() string { + return "" +} diff --git a/cmd/update/update.go b/cmd/update/update.go index c846a518..5d3e5a41 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -57,7 +57,7 @@ func getLatestTag() (string, error) { return info.TagName, nil } -func getAssetName() (asset string, platform string, err error) { +func getAssetName() (asset string, platform string, archName string, linuxSuffix string, err error) { osName := osruntime.GOOS arch := osruntime.GOARCH var ext string @@ -68,13 +68,13 @@ func getAssetName() (asset string, platform string, err error) { case "linux": platform = "linux" ext = ".tar.gz" + linuxSuffix = linuxAssetSuffix() case "windows": platform = "windows" ext = ".zip" default: - return "", "", fmt.Errorf("unsupported OS: %s", osName) + return "", "", "", "", fmt.Errorf("unsupported OS: %s", osName) } - var archName string switch arch { case "amd64", "x86_64": archName = "amd64" @@ -85,10 +85,10 @@ func getAssetName() (asset string, platform string, err error) { archName = "arm64" } default: - return "", "", fmt.Errorf("unsupported architecture: %s", arch) + return "", "", "", "", fmt.Errorf("unsupported architecture: %s", arch) } - asset = fmt.Sprintf("%s_%s_%s%s", cliName, platform, archName, ext) - return asset, platform, nil + asset = fmt.Sprintf("%s_%s_%s%s%s", cliName, platform, archName, linuxSuffix, ext) + return asset, platform, archName, linuxSuffix, nil } func downloadFile(url, dest, message string) error { @@ -336,7 +336,7 @@ func Run(currentVersion string) error { } // If we're here, an update is needed. - asset, _, err := getAssetName() + asset, platform, archName, linuxSuffix, err := getAssetName() if err != nil { spinner.Stop() return fmt.Errorf("error determining asset name: %w", err) @@ -368,6 +368,24 @@ func Run(currentVersion string) error { return fmt.Errorf("extraction failed: %w", err) } + var sigPath string + if platform == "linux" { + sigAsset := getSigAssetName(platform, archName, linuxSuffix) + sigPath = filepath.Join(tmpDir, sigAsset) + sigURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, sigAsset) + sigDownloadMsg := fmt.Sprintf("Downloading signature for %s...", tag) + if err := downloadFile(sigURL, sigPath, sigDownloadMsg); err != nil { + spinner.Stop() + return fmt.Errorf("signature download failed: %w", err) + } + } + + spinner.Update("Verifying release signature...") + if err := verifyReleaseBinary(binPath, sigPath); err != nil { + spinner.Stop() + return fmt.Errorf("release signature verification failed: %w", err) + } + spinner.Update("Installing...") if err := os.Chmod(binPath, 0755); err != nil { spinner.Stop() @@ -396,6 +414,13 @@ func New(_ *runtime.Context) *cobra.Command { // <-- No longer uses rt var versionCmd = &cobra.Command{ Use: "update", Short: "Update the cre CLI to the latest version", + Long: `Update the cre CLI to the latest version + +Release signatures are verified using the public key published by the CRE team. + +On Linux, the signature is verified using GPG. +On macOS, the signature is verified using codesign. +On Windows, the signature is verified using Authenticode.`, RunE: func(cmd *cobra.Command, args []string) error { return Run(version.Version) }, diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go new file mode 100644 index 00000000..04b0bc30 --- /dev/null +++ b/cmd/update/update_test.go @@ -0,0 +1,88 @@ +package update + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "encoding/json" + "net/http" + "path/filepath" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestRun_abortsWhenSignatureVerificationFails(t *testing.T) { + httpmock.ActivateNonDefault(httpClient) + t.Cleanup(httpmock.DeactivateAndReset) + + asset, platform, archName, linuxSuffix, err := getAssetName() + require.NoError(t, err) + + tag := "v99.0.0-test" + httpmock.RegisterResponder("GET", "https://api.github.com/repos/smartcontractkit/cre-cli/releases/latest", + func(_ *http.Request) (*http.Response, error) { + body, _ := json.Marshal(releaseInfo{TagName: tag}) + return httpmock.NewBytesResponse(http.StatusOK, body), nil + }, + ) + + archiveBytes := createTestArchiveBytes(t, asset, tag, platform, archName, []byte("#!/bin/sh\necho test\n")) + + downloadURL := "https://github.com/smartcontractkit/cre-cli/releases/download/" + tag + "/" + asset + httpmock.RegisterResponder("GET", downloadURL, + func(_ *http.Request) (*http.Response, error) { + return httpmock.NewBytesResponse(http.StatusOK, archiveBytes), nil + }, + ) + + if platform == "linux" { + sigAsset := getSigAssetName(platform, archName, linuxSuffix) + sigURL := "https://github.com/smartcontractkit/cre-cli/releases/download/" + tag + "/" + sigAsset + httpmock.RegisterResponder("GET", sigURL, + func(_ *http.Request) (*http.Response, error) { + return httpmock.NewBytesResponse(http.StatusOK, []byte("invalid-signature")), nil + }, + ) + } + + err = Run("version v0.0.1") + require.Error(t, err) + require.Contains(t, err.Error(), "release signature verification failed") +} + +func createTestArchiveBytes(t *testing.T, asset, tag, platform, archName string, content []byte) []byte { + t.Helper() + binName := "cre_" + tag + "_" + platform + "_" + archName + if platform == "windows" { + binName += ".exe" + } + + if filepath.Ext(asset) == ".zip" { + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + w, err := zw.Create(binName) + require.NoError(t, err) + _, err = w.Write(content) + require.NoError(t, err) + require.NoError(t, zw.Close()) + return buf.Bytes() + } + + buf := &bytes.Buffer{} + gz := gzip.NewWriter(buf) + tw := tar.NewWriter(gz) + hdr := &tar.Header{ + Name: binName, + Mode: 0755, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return buf.Bytes() +} diff --git a/cmd/update/verify.go b/cmd/update/verify.go new file mode 100644 index 00000000..124aec59 --- /dev/null +++ b/cmd/update/verify.go @@ -0,0 +1,11 @@ +package update + +const ( + expectedSignerName = "CRE" + expectedSignerEmail = "cre@smartcontract.com" + codesignIdentifier = "com.smartcontract.cre.cli" +) + +func getSigAssetName(platform, archName, linuxSuffix string) string { + return "cre_" + platform + "_" + archName + linuxSuffix + ".sig" +} diff --git a/cmd/update/verify_darwin.go b/cmd/update/verify_darwin.go new file mode 100644 index 00000000..b3b63b83 --- /dev/null +++ b/cmd/update/verify_darwin.go @@ -0,0 +1,24 @@ +//go:build darwin + +package update + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +func verifyReleaseBinary(binPath, _ string) error { + cmd := exec.Command("codesign", "--verify", "--strict", "--identifier", codesignIdentifier, binPath) // #nosec G204 -- fixed args, user-controlled path only + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return fmt.Errorf("codesign verification failed: %s: %w", msg, err) + } + return fmt.Errorf("codesign verification failed: %w", err) + } + return nil +} diff --git a/cmd/update/verify_linux.go b/cmd/update/verify_linux.go new file mode 100644 index 00000000..6bbbecb3 --- /dev/null +++ b/cmd/update/verify_linux.go @@ -0,0 +1,93 @@ +//go:build linux + +//nolint:staticcheck // SA1019: OpenPGP required to verify KMS GPG release signatures. +package update + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/armor" + + "github.com/smartcontractkit/cre-cli/install" +) + +func verifyReleaseBinary(binPath, sigPath string) error { + if sigPath == "" { + return fmt.Errorf("missing signature file path") + } + return verifyGPGSignature(install.ReleasePublicKey, binPath, sigPath) +} + +func verifyGPGSignature(publicKey []byte, binPath, sigPath string) error { + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(publicKey)) + if err != nil { + return fmt.Errorf("parse release public key: %w", err) + } + + signed, err := os.Open(binPath) // #nosec G703 -- path from controlled temp extraction + if err != nil { + return fmt.Errorf("open binary: %w", err) + } + defer signed.Close() + + sigBytes, err := os.ReadFile(sigPath) // #nosec G703 -- path from controlled temp download + if err != nil { + return fmt.Errorf("read signature: %w", err) + } + + entity, err := checkDetachedSignature(keyring, signed, sigBytes) + if err != nil { + return fmt.Errorf("GPG signature invalid: %w", err) + } + + return validateSignerIdentity(entity) +} + +func checkDetachedSignature(keyring openpgp.KeyRing, signed *os.File, sigBytes []byte) (*openpgp.Entity, error) { + if _, err := signed.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("rewind binary: %w", err) + } + + sigReader := bytes.NewReader(sigBytes) + if block, _ := armor.Decode(sigReader); block != nil { + if _, err := signed.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("rewind binary: %w", err) + } + entity, err := openpgp.CheckDetachedSignature(keyring, signed, block.Body) + if err != nil { + return nil, err + } + return entity, nil + } + + if _, err := signed.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("rewind binary: %w", err) + } + entity, err := openpgp.CheckArmoredDetachedSignature(keyring, signed, bytes.NewReader(sigBytes)) + if err != nil { + return nil, err + } + return entity, nil +} + +func validateSignerIdentity(entity *openpgp.Entity) error { + if entity == nil { + return fmt.Errorf("missing signer identity") + } + for _, identity := range entity.Identities { + email := "" + if identity.UserId != nil { + email = identity.UserId.Email + } + if strings.Contains(identity.Name, expectedSignerName) && + strings.EqualFold(email, expectedSignerEmail) { + return nil + } + } + return fmt.Errorf("unexpected signer identity") +} diff --git a/cmd/update/verify_linux_test.go b/cmd/update/verify_linux_test.go new file mode 100644 index 00000000..513599a9 --- /dev/null +++ b/cmd/update/verify_linux_test.go @@ -0,0 +1,121 @@ +//go:build linux + +//nolint:staticcheck // SA1019: OpenPGP required to verify KMS GPG release signatures. +package update + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/armor" + + "github.com/smartcontractkit/cre-cli/install" +) + +func TestVerifyGPGSignature_validSignature(t *testing.T) { + entity, err := openpgp.NewEntity("CRE", "Linux GPG Signing Key", "cre@smartcontract.com", nil) + require.NoError(t, err) + + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "cre") + content := []byte("test-binary-content") + require.NoError(t, os.WriteFile(binPath, content, 0600)) + + sigPath := filepath.Join(tmpDir, "cre.sig") + require.NoError(t, writeArmoredDetachedSignature(sigPath, entity, content)) + + pubKey, err := exportPublicKey(entity) + require.NoError(t, err) + + require.NoError(t, verifyGPGSignature(pubKey, binPath, sigPath)) +} + +func TestVerifyGPGSignature_tamperedBinary(t *testing.T) { + entity, err := openpgp.NewEntity("CRE", "Linux GPG Signing Key", "cre@smartcontract.com", nil) + require.NoError(t, err) + + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "cre") + require.NoError(t, os.WriteFile(binPath, []byte("original"), 0600)) + + sigPath := filepath.Join(tmpDir, "cre.sig") + require.NoError(t, writeArmoredDetachedSignature(sigPath, entity, []byte("original"))) + + pubKey, err := exportPublicKey(entity) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(binPath, []byte("tampered"), 0600)) + err = verifyGPGSignature(pubKey, binPath, sigPath) + require.Error(t, err) + require.Contains(t, err.Error(), "GPG signature invalid") +} + +func TestVerifyGPGSignature_unexpectedSigner(t *testing.T) { + entity, err := openpgp.NewEntity("Other", "Signer", "other@example.com", nil) + require.NoError(t, err) + + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "cre") + content := []byte("test-binary-content") + require.NoError(t, os.WriteFile(binPath, content, 0600)) + + sigPath := filepath.Join(tmpDir, "cre.sig") + require.NoError(t, writeArmoredDetachedSignature(sigPath, entity, content)) + + pubKey, err := exportPublicKey(entity) + require.NoError(t, err) + + err = verifyGPGSignature(pubKey, binPath, sigPath) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected signer identity") +} + +func TestVerifyGPGSignature_embeddedReleaseKeyParses(t *testing.T) { + require.NotEmpty(t, install.ReleasePublicKey) + _, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(install.ReleasePublicKey)) + require.NoError(t, err) +} + +func writeArmoredDetachedSignature(path string, entity *openpgp.Entity, content []byte) error { + var sigBuf bytes.Buffer + armorWriter, err := armor.Encode(&sigBuf, openpgp.SignatureType, nil) + if err != nil { + return err + } + if err := openpgp.DetachSign(armorWriter, entity, bytes.NewReader(content), nil); err != nil { + return err + } + if err := armorWriter.Close(); err != nil { + return err + } + return os.WriteFile(path, sigBuf.Bytes(), 0600) +} + +func exportPublicKey(entity *openpgp.Entity) ([]byte, error) { + var buf bytes.Buffer + armorWriter, err := armor.Encode(&buf, openpgp.PublicKeyType, nil) + if err != nil { + return nil, err + } + if err := entity.Serialize(armorWriter); err != nil { + return nil, err + } + if err := armorWriter.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func TestValidateSignerIdentity(t *testing.T) { + validEntity, err := openpgp.NewEntity("CRE", "Linux GPG Signing Key", "cre@smartcontract.com", nil) + require.NoError(t, err) + require.NoError(t, validateSignerIdentity(validEntity)) + + invalidEntity, err := openpgp.NewEntity("Evil", "Signer", "evil@example.com", nil) + require.NoError(t, err) + require.Error(t, validateSignerIdentity(invalidEntity)) +} diff --git a/cmd/update/verify_test.go b/cmd/update/verify_test.go new file mode 100644 index 00000000..5977ac57 --- /dev/null +++ b/cmd/update/verify_test.go @@ -0,0 +1,21 @@ +package update + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetSigAssetName(t *testing.T) { + require.Equal(t, "cre_linux_amd64.sig", getSigAssetName("linux", "amd64", "")) + require.Equal(t, "cre_linux_amd64_ldd2-35.sig", getSigAssetName("linux", "amd64", "_ldd2-35")) +} + +func TestGetAssetName(t *testing.T) { + asset, platform, archName, linuxSuffix, err := getAssetName() + require.NoError(t, err) + require.NotEmpty(t, asset) + require.NotEmpty(t, platform) + require.NotEmpty(t, archName) + require.Contains(t, asset, "cre_"+platform+"_"+archName+linuxSuffix) +} diff --git a/cmd/update/verify_windows.go b/cmd/update/verify_windows.go new file mode 100644 index 00000000..8ac98f10 --- /dev/null +++ b/cmd/update/verify_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package update + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// windowsSignerSubject is a substring of the Authenticode certificate subject. +const windowsSignerSubject = "SmartContract" + +func verifyReleaseBinary(binPath, _ string) error { + escaped := strings.ReplaceAll(binPath, "'", "''") + script := fmt.Sprintf( + `$ErrorActionPreference = 'Stop'; $s = Get-AuthenticodeSignature -FilePath '%s'; if ($s.Status -ne 'Valid') { Write-Error ('authenticode status: ' + $s.Status); exit 1 }; if (-not $s.SignerCertificate) { Write-Error 'missing signer certificate'; exit 1 }; if ($s.SignerCertificate.Subject -notlike '*%s*') { Write-Error ('unexpected signer: ' + $s.SignerCertificate.Subject); exit 2 }`, + escaped, + windowsSignerSubject, + ) + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script) // #nosec G204 -- script uses escaped path only + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return fmt.Errorf("authenticode verification failed: %s: %w", msg, err) + } + return fmt.Errorf("authenticode verification failed: %w", err) + } + return nil +} diff --git a/cmd/workflow/convert/convert.go b/cmd/workflow/convert/convert.go index 5e17cf04..44df0882 100644 --- a/cmd/workflow/convert/convert.go +++ b/cmd/workflow/convert/convert.go @@ -108,6 +108,9 @@ func (h *handler) Execute(inputs Inputs) error { return fmt.Errorf("cannot detect workflow language: %w", err) } lang := cmdcommon.GetWorkflowLanguage(workflowPath) + if h.runtimeContext != nil { + h.runtimeContext.Workflow.Language = lang + } if lang == constants.WorkflowLanguageWasm { return fmt.Errorf("workflow is already a custom build (workflow-path is %s)", currentPath) } diff --git a/cmd/workflow/convert/convert_test.go b/cmd/workflow/convert/convert_test.go index d15bf3fb..71236363 100644 --- a/cmd/workflow/convert/convert_test.go +++ b/cmd/workflow/convert/convert_test.go @@ -303,7 +303,7 @@ production-settings: ` require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) require.NoError(t, os.WriteFile(mainTS, []byte("export default function run() { return Promise.resolve({ result: \"ok\" }); }\n"), 0600)) - require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name":"test","private":true,"dependencies":{"@chainlink/cre-sdk":"^1.9.0"}}`), 0600)) + require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name":"test","private":true,"dependencies":{"@chainlink/cre-sdk":"1.17.0-alpha.solana-log-trigger.1"}}`), 0600)) h := newHandler(nil) err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) diff --git a/cmd/workflow/deploy/compile.go b/cmd/workflow/deploy/compile.go index ffa42bdf..2118b166 100644 --- a/cmd/workflow/deploy/compile.go +++ b/cmd/workflow/deploy/compile.go @@ -17,6 +17,9 @@ func (h *handler) Compile(ctx context.Context) error { // URL wasm is handled directly in Execute(); nothing to compile or write locally. if cmdcommon.IsURL(h.inputs.WasmPath) { + if h.runtimeContext != nil { + h.runtimeContext.Workflow.Language = constants.WorkflowLanguageWasm + } return nil } @@ -29,14 +32,14 @@ func (h *handler) Compile(ctx context.Context) error { var err error if h.inputs.WasmPath != "" { + if h.runtimeContext != nil { + h.runtimeContext.Workflow.Language = constants.WorkflowLanguageWasm + } ui.Dim("Reading pre-built WASM binary...") wasmFile, err = os.ReadFile(h.inputs.WasmPath) if err != nil { return fmt.Errorf("failed to read WASM binary from %s: %w", h.inputs.WasmPath, err) } - if h.runtimeContext != nil { - h.runtimeContext.Workflow.Language = constants.WorkflowLanguageWasm - } h.log.Debug().Str("path", h.inputs.WasmPath).Msg("Loaded pre-built WASM binary") br64Data, err := cmdcommon.EnsureBrotliBase64(wasmFile) diff --git a/cmd/workflow/deploy/register.go b/cmd/workflow/deploy/register.go index 83e78dc1..2c64abe6 100644 --- a/cmd/workflow/deploy/register.go +++ b/cmd/workflow/deploy/register.go @@ -80,6 +80,14 @@ func (h *handler) handleUpsert(ctx context.Context, params client.RegisterWorkfl if h.inputs.ConfigURL != nil && *h.inputs.ConfigURL != "" { ui.Dim(fmt.Sprintf(" Config URL: %s", *h.inputs.ConfigURL)) } + ui.Line() + ui.Bold("Next steps:") + ui.Dim(" cre workflow list") + ui.Dim(fmt.Sprintf(" cre execution list %s", workflowName)) + ui.Dim(fmt.Sprintf(" cre execution list %s --status FAILURE", workflowName)) + ui.Dim(" cre execution status ") + ui.Dim(" cre execution events ") + ui.Dim(" cre execution logs ") case client.Raw: ui.Line() diff --git a/cmd/workflow/deploy/registry_deploy_strategy_private.go b/cmd/workflow/deploy/registry_deploy_strategy_private.go index c29b3765..cc2ec10d 100644 --- a/cmd/workflow/deploy/registry_deploy_strategy_private.go +++ b/cmd/workflow/deploy/registry_deploy_strategy_private.go @@ -78,6 +78,14 @@ func (a *privateRegistryDeployStrategy) Upsert(ctx context.Context) error { if result.Owner != "" { ui.Dim(fmt.Sprintf(" Owner: %s", result.Owner)) } + ui.Line() + ui.Bold("Next steps:") + ui.Dim(" cre workflow list") + ui.Dim(fmt.Sprintf(" cre execution list %s", result.WorkflowName)) + ui.Dim(fmt.Sprintf(" cre execution list %s --status FAILURE", result.WorkflowName)) + ui.Dim(" cre execution status ") + ui.Dim(" cre execution events ") + ui.Dim(" cre execution logs ") return nil } diff --git a/cmd/workflow/get/get.go b/cmd/workflow/get/get.go index 777fda36..15b5b026 100644 --- a/cmd/workflow/get/get.go +++ b/cmd/workflow/get/get.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "strings" + "sync" + "time" "github.com/spf13/cobra" @@ -14,18 +16,37 @@ import ( "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/ui" - "github.com/smartcontractkit/cre-cli/internal/workflowrender" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" ) +// Inputs holds resolved and validated flag values for workflow get. +type Inputs struct { + AllRegistries bool + OutputFormat string +} + +func resolveInputs(allRegistries bool, outputFormat string, jsonFlag bool) (Inputs, error) { + outputFormat, err := workflowresolve.ResolveOutputFormat(outputFormat, jsonFlag) + if err != nil { + return Inputs{}, err + } + return Inputs{ + AllRegistries: allRegistries, + OutputFormat: outputFormat, + }, nil +} + // Handler resolves a single workflow by name via the platform search API and -// prints the matching rows. It filters to the workflow's configured -// deployment-registry by default; that filter can be disabled with --all-registries. +// prints deployment health and the most recent execution. Results are filtered +// to the workflow's configured deployment-registry by default; pass +// --all-registries to resolve across every registry. type Handler struct { - credentials *credentials.Credentials - tenantCtx *tenantctx.EnvironmentContext - settings *settings.Settings - resolvedRegistry settings.ResolvedRegistry - wdc *workflowdataclient.Client + credentials *credentials.Credentials + tenantCtx *tenantctx.EnvironmentContext + settings *settings.Settings + resolvedRegistry settings.ResolvedRegistry + derivedWorkflowOwner string + wdc *workflowdataclient.Client } // NewHandler builds a Handler backed by a real WorkflowDataClient. @@ -33,11 +54,12 @@ func NewHandler(ctx *runtime.Context) *Handler { gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) wdc := workflowdataclient.New(gql, ctx.Logger) return &Handler{ - credentials: ctx.Credentials, - tenantCtx: ctx.TenantContext, - settings: ctx.Settings, - resolvedRegistry: ctx.ResolvedRegistry, - wdc: wdc, + credentials: ctx.Credentials, + tenantCtx: ctx.TenantContext, + settings: ctx.Settings, + resolvedRegistry: ctx.ResolvedRegistry, + derivedWorkflowOwner: ctx.DerivedWorkflowOwner, + wdc: wdc, } } @@ -45,18 +67,18 @@ func NewHandler(ctx *runtime.Context) *Handler { // (for testing). func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { return &Handler{ - credentials: ctx.Credentials, - tenantCtx: ctx.TenantContext, - settings: ctx.Settings, - resolvedRegistry: ctx.ResolvedRegistry, - wdc: wdc, + credentials: ctx.Credentials, + tenantCtx: ctx.TenantContext, + settings: ctx.Settings, + resolvedRegistry: ctx.ResolvedRegistry, + derivedWorkflowOwner: ctx.DerivedWorkflowOwner, + wdc: wdc, } } -// Execute searches the platform for workflows whose name matches the value in -// the target's user-workflow settings. When allRegistries is false (default), -// results are filtered to the workflow's configured deployment-registry. -func (h *Handler) Execute(ctx context.Context, allRegistries bool) error { +// Execute resolves the workflow configured for the selected --target and prints +// deployment health and the most recent execution. +func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { if h.tenantCtx == nil { return fmt.Errorf("user context not available — run `cre login` and retry") } @@ -72,74 +94,180 @@ func (h *Handler) Execute(ctx context.Context, allRegistries bool) error { return fmt.Errorf("workflow-name is not set for target %q in workflow.yaml", h.settings.User.TargetName) } - // Resolve which registry to filter by (if any). When --all-registries is - // set we skip the filter entirely. Otherwise we use the deployment-registry - // from workflow.yaml; the registry-resolution pass that runs earlier - // already validated the value against the tenant context, so we look it - // up by ID here for a plain *tenantctx.Registry handle to pass to the - // filter. - var registryFilter *tenantctx.Registry - if !allRegistries { - filterID := strings.TrimSpace(h.settings.Workflow.UserWorkflowSettings.DeploymentRegistry) - if filterID == "" && h.resolvedRegistry != nil { - filterID = h.resolvedRegistry.ID() - } - if filterID != "" { - registryFilter = workflowrender.FindRegistry(h.tenantCtx.Registries, filterID) - if registryFilter == nil { - return fmt.Errorf("deployment-registry %q not found in user context; available: [%s]", - filterID, workflowrender.AvailableRegistryIDs(h.tenantCtx.Registries)) - } - } + owner, err := workflowresolve.ResolveWorkflowOwnerAddress(h.settings, h.resolvedRegistry, h.derivedWorkflowOwner) + if err != nil { + return err + } + + uuid, err := h.resolveWorkflowUUID(ctx, inputs, workflowName, owner) + if err != nil { + return err } spinner := ui.NewSpinner() - spinner.Start(fmt.Sprintf("Fetching workflow %q...", workflowName)) - rows, err := h.wdc.SearchByName(ctx, workflowName, workflowdataclient.DefaultPageSize) + spinner.Start("Fetching workflow details...") + + now := time.Now().UTC() + from := now.AddDate(-1, 0, 0) // 1-year lookback — mirrors Explorer behaviour + + var ( + summary *workflowdataclient.WorkflowSummary + deployment *workflowdataclient.WorkflowDeploymentRecord + executions []workflowdataclient.Execution + summaryErr, deployErr, execErr error + wg sync.WaitGroup + ) + + wg.Add(3) + go func() { + defer wg.Done() + summary, summaryErr = h.wdc.GetWorkflowSummary(ctx, uuid, from) + }() + go func() { + defer wg.Done() + deployment, deployErr = h.wdc.GetLatestDeployment(ctx, uuid, from, now) + }() + go func() { + defer wg.Done() + executions, execErr = h.wdc.ListExecutions(ctx, workflowdataclient.ListExecutionsInput{ + WorkflowUUID: &uuid, + Limit: 1, + }) + }() + wg.Wait() spinner.Stop() + + if summaryErr != nil { + return summaryErr + } + if execErr != nil { + return execErr + } + if deployErr != nil { + deployment = nil + ui.Warning(fmt.Sprintf("Could not fetch deployment record: %s", deployErr.Error())) + } + + var lastExec *workflowdataclient.Execution + if len(executions) > 0 { + lastExec = &executions[0] + } + + view := workflowresolve.WorkflowStatusView{ + Summary: summary, + Deployment: deployment, + DeploymentErr: deployErr, + LastExecution: lastExec, + Registries: h.tenantCtx.Registries, + } + + if inputs.OutputFormat == workflowresolve.OutputFormatJSON { + return workflowresolve.PrintWorkflowStatusJSON(view) + } + workflowresolve.PrintWorkflowStatusTable(view) + return nil +} + +func (h *Handler) registryFilter(inputs Inputs) (*tenantctx.Registry, error) { + if inputs.AllRegistries { + return nil, nil + } + filterID := strings.TrimSpace(h.settings.Workflow.UserWorkflowSettings.DeploymentRegistry) + if filterID == "" && h.resolvedRegistry != nil { + filterID = h.resolvedRegistry.ID() + } + if filterID == "" { + return nil, nil + } + registryFilter := workflowresolve.FindRegistry(h.tenantCtx.Registries, filterID) + if registryFilter == nil { + return nil, fmt.Errorf("deployment-registry %q not found in user context; available: [%s]", + filterID, workflowresolve.AvailableRegistryIDs(h.tenantCtx.Registries)) + } + return registryFilter, nil +} + +func (h *Handler) resolveWorkflowUUID(ctx context.Context, inputs Inputs, workflowName, owner string) (string, error) { + registryFilter, err := h.registryFilter(inputs) if err != nil { - return err + return "", err } - // The platform search uses a contains-style match; narrow to an exact - // (case-insensitive) name match so `get foo` does not surface `foo-staging`. - rows = filterByExactName(rows, workflowName) + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving workflow %q...", workflowName)) + rows, err := h.wdc.SearchByName(ctx, workflowName, workflowdataclient.DefaultPageSize, owner) + spinner.Stop() + if err != nil { + return "", fmt.Errorf("resolving workflow name %q: %w", workflowName, err) + } + rows = filterByExactName(rows, workflowName) if registryFilter != nil { - rows = workflowrender.FilterRowsByRegistry(rows, registryFilter, h.tenantCtx.Registries) + rows = workflowresolve.FilterRowsByRegistry(rows, registryFilter, h.tenantCtx.Registries) } - workflowrender.PrintWorkflowTable(rows, h.tenantCtx.Registries, workflowrender.TableOptions{}) - return nil + if len(rows) == 0 { + return "", fmt.Errorf("no workflow found with name %q", workflowName) + } + + var active []workflowresolve.Workflow + for _, r := range rows { + if strings.EqualFold(r.Status, "ACTIVE") { + active = append(active, r) + } + } + if len(active) == 1 { + if active[0].UUID == "" { + return "", fmt.Errorf("workflow %q resolved but has no platform UUID", workflowName) + } + return active[0].UUID, nil + } + if len(active) > 1 { + return "", fmt.Errorf("multiple ACTIVE workflows named %q found; narrow the deployment-registry in workflow.yaml or pass --all-registries", workflowName) + } + + if rows[0].UUID == "" { + return "", fmt.Errorf("workflow %q resolved but has no platform UUID", workflowName) + } + ui.Warning(fmt.Sprintf("No ACTIVE deployment for workflow %q; using the first match (status: %s)", workflowName, rows[0].Status)) + return rows[0].UUID, nil } // New returns the cobra command. func New(runtimeContext *runtime.Context) *cobra.Command { var allRegistries bool + var outputFormat string + var jsonFlag bool cmd := &cobra.Command{ Use: "get ", - Short: "Shows metadata for the workflow configured in workflow.yaml", + Short: "Show deployment health and recent execution for the workflow in workflow.yaml", Long: `Looks up the workflow whose name is configured for the selected --target in ` + - `workflow.yaml and prints its metadata from the CRE platform. By default results ` + - `are filtered to the workflow's configured deployment-registry; pass --all-registries ` + - `to show matches from every registry.`, + `workflow.yaml and prints deployment health and the most recent execution from ` + + `the CRE platform. By default resolution is scoped to the workflow's configured ` + + `deployment-registry; pass --all-registries to resolve across every registry.`, Example: `cre workflow get ./my-workflow --target staging - cre workflow get ./my-workflow --target staging --all-registries`, + cre workflow get ./my-workflow --target staging --all-registries + cre workflow get ./my-workflow --target staging --output json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return NewHandler(runtimeContext).Execute(cmd.Context(), allRegistries) + inputs, err := resolveInputs(allRegistries, outputFormat, jsonFlag) + if err != nil { + return err + } + return NewHandler(runtimeContext).Execute(cmd.Context(), inputs) }, } cmd.Flags().BoolVar(&allRegistries, "all-registries", false, - "Do not filter results by the workflow's deployment-registry") - + "Resolve the workflow across every registry instead of the configured deployment-registry") + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints JSON to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") return cmd } -func filterByExactName(rows []workflowrender.Workflow, name string) []workflowrender.Workflow { - out := make([]workflowrender.Workflow, 0, len(rows)) +func filterByExactName(rows []workflowresolve.Workflow, name string) []workflowresolve.Workflow { + out := make([]workflowresolve.Workflow, 0, len(rows)) for _, r := range rows { if strings.EqualFold(strings.TrimSpace(r.Name), name) { out = append(out, r) diff --git a/cmd/workflow/get/get_test.go b/cmd/workflow/get/get_test.go index 95943192..a5334a76 100644 --- a/cmd/workflow/get/get_test.go +++ b/cmd/workflow/get/get_test.go @@ -8,8 +8,8 @@ import ( "net/http/httptest" "os" "strings" - "sync/atomic" "testing" + "time" "github.com/rs/zerolog" @@ -25,39 +25,6 @@ import ( func strPtr(s string) *string { return &s } -// workflowServer starts an httptest.Server that responds to ListWorkflows -// with the provided pages (each call advances through pages) and records the -// raw request bodies so tests can assert the GQL variables that were sent. -type workflowServer struct { - *httptest.Server - requests []string -} - -func newWorkflowServer(t *testing.T, pages [][]map[string]string, totalCount int) *workflowServer { - t.Helper() - ws := &workflowServer{} - var call atomic.Int32 - ws.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - ws.requests = append(ws.requests, string(body)) - idx := int(call.Add(1)) - 1 - w.Header().Set("Content-Type", "application/json") - var data []map[string]string - if idx < len(pages) { - data = pages[idx] - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "workflows": map[string]any{ - "count": totalCount, - "data": data, - }, - }, - }) - })) - return ws -} - // buildSettings returns a minimal *settings.Settings populated with the // workflow-name and deployment-registry under the "staging" target. func buildSettings(workflowName, deploymentRegistry string) *settings.Settings { @@ -69,7 +36,7 @@ func buildSettings(workflowName, deploymentRegistry string) *settings.Settings { return s } -func newHandlerWithServer(t *testing.T, rtCtx *runtime.Context, srv *workflowServer) *cmdget.Handler { +func newHandlerWithServer(t *testing.T, rtCtx *runtime.Context, srv *httptest.Server) *cmdget.Handler { t.Helper() logger := zerolog.Nop() creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} @@ -101,22 +68,6 @@ func captureStdout(t *testing.T, fn func()) string { return buf.String() } -func captureStderr(t *testing.T, fn func()) string { - t.Helper() - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - old := os.Stderr - os.Stderr = w - fn() - w.Close() - os.Stderr = old - var buf strings.Builder - _, _ = io.Copy(&buf, r) - return buf.String() -} - func TestExecute_NoTenantContext(t *testing.T) { logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ @@ -127,7 +78,7 @@ func TestExecute_NoTenantContext(t *testing.T) { } h := cmdget.NewHandlerWithClient(rtCtx, nil) - err := h.Execute(context.Background(), false) + err := h.Execute(context.Background(), cmdget.Inputs{}) if err == nil || !strings.Contains(err.Error(), "user context not available") { t.Fatalf("expected tenant-context error, got %v", err) } @@ -143,7 +94,7 @@ func TestExecute_NoCredentials(t *testing.T) { } h := cmdget.NewHandlerWithClient(rtCtx, nil) - err := h.Execute(context.Background(), false) + err := h.Execute(context.Background(), cmdget.Inputs{}) if err == nil || !strings.Contains(err.Error(), "credentials not available") { t.Fatalf("expected credentials error, got %v", err) } @@ -160,7 +111,7 @@ func TestExecute_MissingWorkflowName(t *testing.T) { } h := cmdget.NewHandlerWithClient(rtCtx, nil) - err := h.Execute(context.Background(), false) + err := h.Execute(context.Background(), cmdget.Inputs{}) if err == nil || !strings.Contains(err.Error(), "workflow-name is not set") { t.Fatalf("expected missing workflow-name error, got %v", err) } @@ -179,18 +130,79 @@ func TestExecute_UnknownDeploymentRegistry(t *testing.T) { } h := cmdget.NewHandlerWithClient(rtCtx, nil) - err := h.Execute(context.Background(), false) + err := h.Execute(context.Background(), cmdget.Inputs{}) if err == nil || !strings.Contains(err.Error(), "not found in user context") { t.Fatalf("expected unknown registry error, got %v", err) } } func TestExecute_FiltersByDeploymentRegistry(t *testing.T) { + registered := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + query, _ := body["query"].(string) + + switch { + case strings.Contains(query, "ListWorkflows"): + gqlRespond(w, map[string]any{ + "workflows": map[string]any{ + "count": 2, + "data": []any{ + map[string]any{ + "uuid": "wf-private", + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", + "status": "ACTIVE", + "workflowSource": "private", + }, + map[string]any{ + "uuid": "wf-onchain", + "name": "alpha", + "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", + "ownerAddress": "4040404040404040404040404040404040404040", + "status": "ACTIVE", + "workflowSource": "contract:12345678901234567890:0xcafebabe00000000000000000000000000feed", + }, + }, + }, + }) + case strings.Contains(query, "GetWorkflow"): + gqlRespond(w, map[string]any{ + "workflow": map[string]any{ + "data": map[string]any{ + "uuid": "wf-private", + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", + "status": "ACTIVE", + "workflowSource": "private", + "registeredAt": registered.Format(time.RFC3339), + "executionCount": 0, + "executionCountByStatus": map[string]any{ + "success": 0, + "failure": 0, + }, + }, + }, + }) + case strings.Contains(query, "GetLatestDeployment"): + gqlRespond(w, map[string]any{"workflowDeployments": map[string]any{"data": []any{}}}) + case strings.Contains(query, "ListExecutions"): + gqlRespond(w, map[string]any{"workflowExecutions": map[string]any{"count": 0, "data": []any{}}}) + default: + gqlRespond(w, map[string]any{}) + } + })) + defer srv.Close() + logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, - Credentials: &credentials.Credentials{}, - EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + Credentials: &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "k"}, + EnvironmentSet: &environments.EnvironmentSet{GraphQLURL: srv.URL}, TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{ { @@ -201,41 +213,16 @@ func TestExecute_FiltersByDeploymentRegistry(t *testing.T) { {ID: "private", Type: "off-chain"}, }, }, - Settings: buildSettings("alpha", "private"), - } - - // Server returns two rows with the same name, on two different registries. - page := []map[string]string{ - { - "name": "alpha", - "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", - "ownerAddress": "2020202020202020202020202020202020202020", - "status": "ACTIVE", - "workflowSource": "private", - }, - { - "name": "alpha", - "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", - "ownerAddress": "4040404040404040404040404040404040404040", - "status": "ACTIVE", - "workflowSource": "contract:12345678901234567890:0xcafebabe00000000000000000000000000feed", - }, + Settings: buildSettingsWithOwner("alpha", "0xowner"), } - srv := newWorkflowServer(t, [][]map[string]string{page}, len(page)) - defer srv.Close() h := newHandlerWithServer(t, rtCtx, srv) out := captureStdout(t, func() { - if err := h.Execute(context.Background(), false); err != nil { + if err := h.Execute(context.Background(), cmdget.Inputs{}); err != nil { t.Fatalf("unexpected error: %v", err) } }) - // Only the row whose source resolves to the "private" deployment-registry - // should be printed. - if got := strings.Count(out, "1. alpha"); got != 1 { - t.Errorf("expected exactly one workflow row, got %d:\n%s", got, out) - } wantID := "1010101010101010101010101010101010101010101010101010101010101010" if !strings.Contains(out, wantID) { t.Errorf("expected private-registry workflow id in output:\n%s", out) @@ -243,19 +230,41 @@ func TestExecute_FiltersByDeploymentRegistry(t *testing.T) { if strings.Contains(out, "3030303030303030303030303030303030303030303030303030303030303030") { t.Errorf("on-chain row should have been filtered out:\n%s", out) } - - // The GQL call should have forwarded the workflow name in the search arg. - if len(srv.requests) == 0 || !strings.Contains(srv.requests[0], `"search":"alpha"`) { - t.Errorf("expected search variable to be set to workflow name; requests=%v", srv.requests) - } } -func TestExecute_AllRegistriesSkipsFilter(t *testing.T) { +func TestExecute_AllRegistriesWithMultipleActiveErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflows": map[string]any{ + "count": 2, + "data": []any{ + map[string]any{ + "uuid": "wf-private", + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + }, + map[string]any{ + "uuid": "wf-onchain", + "name": "alpha", + "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "contract:12345678901234567890:0xcafebabe00000000000000000000000000feed", + }, + }, + }, + }) + })) + defer srv.Close() + logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, - Credentials: &credentials.Credentials{}, - EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + Credentials: &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "k"}, + EnvironmentSet: &environments.EnvironmentSet{GraphQLURL: srv.URL}, TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{ { @@ -266,136 +275,455 @@ func TestExecute_AllRegistriesSkipsFilter(t *testing.T) { {ID: "private", Type: "off-chain"}, }, }, - Settings: buildSettings("alpha", "private"), + Settings: buildSettingsWithOwner("alpha", "0xowner"), } + h := newHandlerWithServer(t, rtCtx, srv) - page := []map[string]string{ - { - "name": "alpha", - "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", - "ownerAddress": "2020202020202020202020202020202020202020", - "status": "ACTIVE", - "workflowSource": "private", - }, - { - "name": "alpha", - "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", - "ownerAddress": "4040404040404040404040404040404040404040", - "status": "ACTIVE", - "workflowSource": "contract:12345678901234567890:0xcafebabe00000000000000000000000000feed", - }, + err := h.Execute(context.Background(), cmdget.Inputs{AllRegistries: true}) + if err == nil || !strings.Contains(err.Error(), "multiple ACTIVE workflows") { + t.Fatalf("expected multiple ACTIVE error, got %v", err) } - srv := newWorkflowServer(t, [][]map[string]string{page}, len(page)) +} + +func TestExecute_ExactNameMatchNarrowsSearch(t *testing.T) { + registered := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + query, _ := body["query"].(string) + + switch { + case strings.Contains(query, "ListWorkflows"): + gqlRespond(w, map[string]any{ + "workflows": map[string]any{ + "count": 2, + "data": []any{ + map[string]any{ + "uuid": "wf-alpha", + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + }, + map[string]any{ + "uuid": "wf-alpha-staging", + "name": "alpha-staging", + "workflowId": "5050505050505050505050505050505050505050505050505050505050505050", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }) + case strings.Contains(query, "GetWorkflow"): + gqlRespond(w, map[string]any{ + "workflow": map[string]any{ + "data": map[string]any{ + "uuid": "wf-alpha", + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + "registeredAt": registered.Format(time.RFC3339), + "executionCount": 0, + "executionCountByStatus": map[string]any{ + "success": 0, + "failure": 0, + }, + }, + }, + }) + case strings.Contains(query, "GetLatestDeployment"): + gqlRespond(w, map[string]any{"workflowDeployments": map[string]any{"data": []any{}}}) + case strings.Contains(query, "ListExecutions"): + gqlRespond(w, map[string]any{"workflowExecutions": map[string]any{"count": 0, "data": []any{}}}) + default: + gqlRespond(w, map[string]any{}) + } + })) defer srv.Close() + + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "k"}, + EnvironmentSet: &environments.EnvironmentSet{GraphQLURL: srv.URL}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Type: "off-chain"}}, + }, + Settings: buildSettingsWithOwner("alpha", "0xowner"), + } h := newHandlerWithServer(t, rtCtx, srv) out := captureStdout(t, func() { - if err := h.Execute(context.Background(), true); err != nil { + if err := h.Execute(context.Background(), cmdget.Inputs{}); err != nil { t.Fatalf("unexpected error: %v", err) } }) - if got := strings.Count(out, "alpha"); got < 2 { - t.Errorf("expected both rows when --all-registries is set:\n%s", out) + if !strings.Contains(out, "Workflow: alpha") { + t.Errorf("expected exact-match row in output:\n%s", out) + } + if strings.Contains(out, "alpha-staging") { + t.Errorf("did not expect substring-only match alpha-staging:\n%s", out) + } +} + +func TestExecute_NoMatchReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflows": map[string]any{"data": []any{}, "count": 0}, + }) + })) + defer srv.Close() + + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "k"}, + EnvironmentSet: &environments.EnvironmentSet{GraphQLURL: srv.URL}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Type: "off-chain"}}, + }, + Settings: buildSettingsWithOwner("alpha", "0xowner"), } - if !strings.Contains(out, "0xcafebabe00000000000000000000000000feed") { - t.Errorf("expected on-chain row to appear with --all-registries:\n%s", out) + h := newHandlerWithServer(t, rtCtx, srv) + + err := h.Execute(context.Background(), cmdget.Inputs{}) + if err == nil || !strings.Contains(err.Error(), `no workflow found with name "alpha"`) { + t.Fatalf("expected not-found error, got %v", err) } } -func TestExecute_ExactNameMatchNarrowsSearch(t *testing.T) { +func TestNew_RequiresOneArg(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{}, + TenantContext: &tenantctx.EnvironmentContext{}, + Settings: buildSettings("alpha", "private"), + } + + cmd := cmdget.New(rtCtx) + cmd.SetArgs([]string{}) // no args + cmd.SilenceUsage = true + cmd.SilenceErrors = true + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when no workflow folder path is provided") + } +} + +func buildSettingsWithOwner(workflowName, owner string) *settings.Settings { + s := buildSettings(workflowName, "private") + s.Workflow.UserWorkflowSettings.WorkflowOwnerAddress = owner + return s +} + +func gqlRespond(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": payload}) +} + +func gqlError(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]any{{"message": msg}}, + }) +} + +func TestExecute_MissingSettings(t *testing.T) { logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, Credentials: &credentials.Credentials{}, EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{}, + } + + h := cmdget.NewHandlerWithClient(rtCtx, nil) + err := h.Execute(context.Background(), cmdget.Inputs{}) + if err == nil || !strings.Contains(err.Error(), "workflow settings not loaded") { + t.Fatalf("expected missing settings error, got %v", err) + } +} + +func TestExecute_WorkflowNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflows": map[string]any{"data": []any{}, "count": 0}, + }) + })) + defer srv.Close() + + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "k"}, + EnvironmentSet: &environments.EnvironmentSet{GraphQLURL: srv.URL}, TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{{ID: "private", Type: "off-chain"}}, }, - Settings: buildSettings("alpha", "private"), - } - - // The platform search matches substrings, so the server returns both - // "alpha" and "alpha-staging" — only the exact match should be printed. - page := []map[string]string{ - { - "name": "alpha", - "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", - "ownerAddress": "2020202020202020202020202020202020202020", - "status": "ACTIVE", - "workflowSource": "private", - }, - { - "name": "alpha-staging", - "workflowId": "5050505050505050505050505050505050505050505050505050505050505050", - "ownerAddress": "6060606060606060606060606060606060606060", - "status": "ACTIVE", - "workflowSource": "private", - }, + Settings: buildSettingsWithOwner("missing-workflow", "0xowner"), + } + h := newHandlerWithServer(t, rtCtx, srv) + + err := h.Execute(context.Background(), cmdget.Inputs{}) + if err == nil || !strings.Contains(err.Error(), `no workflow found with name "missing-workflow"`) { + t.Fatalf("expected not-found error, got %v", err) } - srv := newWorkflowServer(t, [][]map[string]string{page}, len(page)) +} + +func TestExecute_JSONOutput(t *testing.T) { + registered := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) + executed := time.Date(2026, 1, 15, 8, 30, 0, 0, time.UTC) + deployed := time.Date(2026, 1, 10, 11, 55, 0, 0, time.UTC) + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + finished := time.Date(2026, 5, 29, 14, 0, 17, 0, time.UTC) + txHash := "0xabc123" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + query, _ := body["query"].(string) + + switch { + case strings.Contains(query, "ListWorkflows"): + gqlRespond(w, map[string]any{ + "workflows": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "wf-uuid-1", + "name": "my-workflow", + "workflowId": "abc123onchain", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }) + case strings.Contains(query, "GetWorkflow"): + gqlRespond(w, map[string]any{ + "workflow": map[string]any{ + "data": map[string]any{ + "uuid": "wf-uuid-1", + "name": "my-workflow", + "workflowId": "abc123onchain", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + "registeredAt": registered.Format(time.RFC3339), + "executedAt": executed.Format(time.RFC3339), + "executionCount": 42, + "executionCountByStatus": map[string]any{ + "success": 40, + "failure": 2, + }, + }, + }, + }) + case strings.Contains(query, "GetLatestDeployment"): + gqlRespond(w, map[string]any{ + "workflowDeployments": map[string]any{ + "data": []any{ + map[string]any{ + "uuid": "dep-uuid-1", + "workflowID": "abc123onchain", + "status": "SUCCESS", + "deployedAt": deployed.Format(time.RFC3339), + "txHash": txHash, + }, + }, + }, + }) + case strings.Contains(query, "ListExecutions"): + gqlRespond(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "exec-uuid-1", + "workflowUUID": "wf-uuid-1", + "workflowName": "my-workflow", + "status": "SUCCESS", + "startedAt": started.Format(time.RFC3339), + "finishedAt": finished.Format(time.RFC3339), + "errors": []any{}, + }, + }, + }, + }) + default: + t.Errorf("unexpected query: %s", query) + gqlRespond(w, map[string]any{}) + } + })) defer srv.Close() + + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "k"}, + EnvironmentSet: &environments.EnvironmentSet{GraphQLURL: srv.URL}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Type: "off-chain"}}, + }, + Settings: buildSettingsWithOwner("my-workflow", "0xowner"), + } h := newHandlerWithServer(t, rtCtx, srv) out := captureStdout(t, func() { - if err := h.Execute(context.Background(), false); err != nil { + if err := h.Execute(context.Background(), cmdget.Inputs{OutputFormat: "json"}); err != nil { t.Fatalf("unexpected error: %v", err) } }) - if !strings.Contains(out, "1. alpha") { - t.Errorf("expected exact-match row in output:\n%s", out) + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, out) } - if strings.Contains(out, "alpha-staging") { - t.Errorf("did not expect substring-only match alpha-staging:\n%s", out) + + workflow, ok := result["workflow"].(map[string]any) + if !ok { + t.Fatalf("expected workflow object in JSON: %s", out) + } + if workflow["name"] != "my-workflow" { + t.Errorf("expected name my-workflow, got %v", workflow["name"]) + } + if workflow["ownerAddress"] != "0xowner" { + t.Errorf("expected ownerAddress 0xowner, got %v", workflow["ownerAddress"]) + } + if _, hasExecCount := workflow["executionCount"]; hasExecCount { + t.Errorf("executionCount should not be in JSON output") + } + + deployment, ok := result["deployment"].(map[string]any) + if !ok { + t.Fatalf("expected deployment object in JSON: %s", out) + } + if deployment["txHash"] != txHash { + t.Errorf("expected txHash %s, got %v", txHash, deployment["txHash"]) + } + + lastExec, ok := result["lastExecution"].(map[string]any) + if !ok { + t.Fatalf("expected lastExecution object in JSON: %s", out) + } + if lastExec["uuid"] != "exec-uuid-1" { + t.Errorf("expected exec uuid, got %v", lastExec["uuid"]) + } + if strings.Contains(out, "Debug further:") || strings.Contains(out, "cre execution") { + t.Errorf("JSON output must not contain discovery hints:\n%s", out) } } -func TestExecute_NoMatchPrintsEmptyState(t *testing.T) { +func TestExecute_ContinuesWhenDeploymentUnavailable(t *testing.T) { + registered := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + query, _ := body["query"].(string) + + switch { + case strings.Contains(query, "ListWorkflows"): + gqlRespond(w, map[string]any{ + "workflows": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "wf-uuid-1", + "name": "my-workflow", + "workflowId": "abc123onchain", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }) + case strings.Contains(query, "GetWorkflow"): + gqlRespond(w, map[string]any{ + "workflow": map[string]any{ + "data": map[string]any{ + "uuid": "wf-uuid-1", + "name": "my-workflow", + "workflowId": "abc123onchain", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + "registeredAt": registered.Format(time.RFC3339), + "executionCount": 0, + "executionCountByStatus": map[string]any{ + "success": 0, + "failure": 0, + }, + }, + }, + }) + case strings.Contains(query, "GetLatestDeployment"): + gqlError(w, "deployment service unavailable") + case strings.Contains(query, "ListExecutions"): + gqlRespond(w, map[string]any{ + "workflowExecutions": map[string]any{"count": 0, "data": []any{}}, + }) + default: + gqlRespond(w, map[string]any{}) + } + })) + defer srv.Close() + logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, - Credentials: &credentials.Credentials{}, - EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + Credentials: &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "k"}, + EnvironmentSet: &environments.EnvironmentSet{GraphQLURL: srv.URL}, TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{{ID: "private", Type: "off-chain"}}, }, - Settings: buildSettings("alpha", "private"), + Settings: buildSettingsWithOwner("my-workflow", "0xowner"), } - - srv := newWorkflowServer(t, [][]map[string]string{{}}, 0) - defer srv.Close() h := newHandlerWithServer(t, rtCtx, srv) - var errOut string - captureStdout(t, func() { - errOut = captureStderr(t, func() { - if err := h.Execute(context.Background(), false); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), cmdget.Inputs{OutputFormat: "json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } }) - if !strings.Contains(errOut, "No workflows found") { - t.Errorf("expected empty-state warning on stderr; got:\n%s", errOut) + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if _, ok := result["workflow"]; !ok { + t.Fatal("expected workflow in output") + } + if _, ok := result["deployment"]; ok { + t.Fatal("deployment should be omitted when unavailable") } } -func TestNew_RequiresOneArg(t *testing.T) { +func TestNew_InvalidOutputFormat(t *testing.T) { logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, Credentials: &credentials.Credentials{}, EnvironmentSet: &environments.EnvironmentSet{}, TenantContext: &tenantctx.EnvironmentContext{}, - Settings: buildSettings("alpha", "private"), + Settings: buildSettingsWithOwner("my-workflow", "0xowner"), } cmd := cmdget.New(rtCtx) - cmd.SetArgs([]string{}) // no args + cmd.SetArgs([]string{"./my-workflow", "--output", "csv"}) cmd.SilenceUsage = true cmd.SilenceErrors = true - if err := cmd.Execute(); err == nil { - t.Fatal("expected error when no workflow folder path is provided") + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "csv") { + t.Fatalf("expected invalid output format error, got %v", err) } } diff --git a/cmd/workflow/list/inputs_test.go b/cmd/workflow/list/inputs_test.go index 097b02ee..65412644 100644 --- a/cmd/workflow/list/inputs_test.go +++ b/cmd/workflow/list/inputs_test.go @@ -8,13 +8,19 @@ import ( ) func TestResolveInputs_OutputFormat_Empty(t *testing.T) { - inputs, err := resolveInputs("", false, "") + inputs, err := resolveInputs("", false, "", false) require.NoError(t, err) assert.Equal(t, "", inputs.OutputFormat) } func TestResolveInputs_OutputFormat_JSON(t *testing.T) { - inputs, err := resolveInputs("", false, "json") + inputs, err := resolveInputs("", false, "json", false) + require.NoError(t, err) + assert.Equal(t, "json", inputs.OutputFormat) +} + +func TestResolveInputs_OutputFormat_JSONShorthand(t *testing.T) { + inputs, err := resolveInputs("", false, "", true) require.NoError(t, err) assert.Equal(t, "json", inputs.OutputFormat) } @@ -23,7 +29,7 @@ func TestResolveInputs_OutputFormat_RejectsUnsupported(t *testing.T) { cases := []string{"csv", "yaml", "table", "text", "JSON", "Json", "/path/to/file.json"} for _, format := range cases { t.Run(format, func(t *testing.T) { - _, err := resolveInputs("", false, format) + _, err := resolveInputs("", false, format, false) require.Error(t, err, "expected error for unsupported format %q", format) assert.Contains(t, err.Error(), "json") }) @@ -31,7 +37,7 @@ func TestResolveInputs_OutputFormat_RejectsUnsupported(t *testing.T) { } func TestResolveInputs_PassthroughFields(t *testing.T) { - inputs, err := resolveInputs("private", true, "") + inputs, err := resolveInputs("private", true, "", false) require.NoError(t, err) assert.Equal(t, "private", inputs.RegistryFilter) assert.True(t, inputs.IncludeDeleted) diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index eed93d49..53a4aef2 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -12,11 +12,9 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/ui" - "github.com/smartcontractkit/cre-cli/internal/workflowrender" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" ) -const outputFormatJSON = "json" - // Inputs holds the resolved and validated flag values for the list command. type Inputs struct { RegistryFilter string @@ -28,9 +26,10 @@ type Inputs struct { // resolveInputs builds Inputs from raw flag values, validating that the // output format (if provided) is a recognised value. -func resolveInputs(registryFilter string, includeDeleted bool, outputFormat string) (Inputs, error) { - if outputFormat != "" && outputFormat != outputFormatJSON { - return Inputs{}, fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) +func resolveInputs(registryFilter string, includeDeleted bool, outputFormat string, jsonFlag bool) (Inputs, error) { + outputFormat, err := workflowresolve.ResolveOutputFormat(outputFormat, jsonFlag) + if err != nil { + return Inputs{}, err } return Inputs{ RegistryFilter: registryFilter, @@ -80,9 +79,9 @@ func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { } if inputs.RegistryFilter != "" { - if workflowrender.FindRegistry(h.tenantCtx.Registries, inputs.RegistryFilter) == nil { + if workflowresolve.FindRegistry(h.tenantCtx.Registries, inputs.RegistryFilter) == nil { return fmt.Errorf("registry %q not found in user context; available: [%s]", - inputs.RegistryFilter, workflowrender.AvailableRegistryIDs(h.tenantCtx.Registries)) + inputs.RegistryFilter, workflowresolve.AvailableRegistryIDs(h.tenantCtx.Registries)) } } @@ -95,23 +94,34 @@ func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { } if inputs.RegistryFilter != "" { - reg := workflowrender.FindRegistry(h.tenantCtx.Registries, inputs.RegistryFilter) - rows = workflowrender.FilterRowsByRegistry(rows, reg, h.tenantCtx.Registries) + reg := workflowresolve.FindRegistry(h.tenantCtx.Registries, inputs.RegistryFilter) + rows = workflowresolve.FilterRowsByRegistry(rows, reg, h.tenantCtx.Registries) } afterRegistryFilter := len(rows) if !inputs.IncludeDeleted { - rows = workflowrender.OmitDeleted(rows) + rows = workflowresolve.OmitDeleted(rows) } - if inputs.OutputFormat == outputFormatJSON { - return workflowrender.PrintWorkflowsJSON(rows, h.tenantCtx.Registries) + if inputs.OutputFormat == workflowresolve.OutputFormatJSON { + return workflowresolve.PrintWorkflowsJSON(rows, h.tenantCtx.Registries) } - workflowrender.PrintWorkflowTable(rows, h.tenantCtx.Registries, workflowrender.TableOptions{ + workflowresolve.PrintWorkflowTable(rows, h.tenantCtx.Registries, workflowresolve.TableOptions{ CountBeforeDeletedFilter: afterRegistryFilter, IncludeDeleted: inputs.IncludeDeleted, }) + + if len(rows) > 0 { + ui.Bold("Inspect executions:") + ui.Dim(" cre execution list ") + ui.Dim(" cre execution list --status FAILURE") + ui.Dim(" cre execution status ") + ui.Dim(" cre execution events ") + ui.Dim(" cre execution logs ") + ui.Line() + } + return nil } @@ -120,6 +130,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { var registryID string var includeDeleted bool var outputFormat string + var jsonFlag bool cmd := &cobra.Command{ Use: "list", @@ -132,7 +143,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { " cre workflow list --output json > workflows.json", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - inputs, err := resolveInputs(registryID, includeDeleted, outputFormat) + inputs, err := resolveInputs(registryID, includeDeleted, outputFormat, jsonFlag) if err != nil { return err } @@ -143,5 +154,6 @@ func New(runtimeContext *runtime.Context) *cobra.Command { cmd.Flags().StringVar(®istryID, "registry", "", "Filter by registry ID from user context") cmd.Flags().BoolVar(&includeDeleted, "include-deleted", false, "Include workflows in DELETED status") cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") return cmd } diff --git a/cmd/workflow/list/list_test.go b/cmd/workflow/list/list_test.go index 361fc20f..792f3464 100644 --- a/cmd/workflow/list/list_test.go +++ b/cmd/workflow/list/list_test.go @@ -243,6 +243,9 @@ func TestExecute_WithMock_PrintsWorkflowBlocks(t *testing.T) { "4040404040404040404040404040404040404040", "PAUSED", "contract:999888777666555444333:0xabababababababababababababababababababab", + "Inspect executions:", + "cre execution list ", + "cre execution status ", } { if !strings.Contains(out, want) { t.Errorf("output missing %q:\n%s", want, out) @@ -679,6 +682,9 @@ func TestExecute_JSONOutput_PrintsToStdout(t *testing.T) { if result[0]["status"] != "ACTIVE" { t.Errorf("expected status=ACTIVE for alpha, got %v", result[0]["status"]) } + if strings.Contains(out, "Inspect executions:") || strings.Contains(out, "cre execution") { + t.Errorf("JSON output must not contain discovery hints:\n%s", out) + } } func TestExecute_JSONOutput_IncludeDeleted(t *testing.T) { diff --git a/cmd/workflow/simulate/capabilities.go b/cmd/workflow/simulate/capabilities.go index 87c8a220..d96f7f24 100644 --- a/cmd/workflow/simulate/capabilities.go +++ b/cmd/workflow/simulate/capabilities.go @@ -12,16 +12,13 @@ import ( consensusserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/consensus/server" crontrigger "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron/server" httptrigger "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http/server" + "github.com/smartcontractkit/chainlink-common/pkg/config" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services" "github.com/smartcontractkit/chainlink/v2/core/capabilities" "github.com/smartcontractkit/chainlink/v2/core/capabilities/fakes" ) -// httpTriggerServerPort is the port on which the local HTTP server listens -// when no --http-payload flag is supplied and the user chooses to POST the payload. -const httpTriggerServerPort = 2000 - // ManualTriggers holds chain-agnostic trigger services used in simulation. type ManualTriggers struct { ManualCronTrigger *fakes.ManualCronTriggerService @@ -30,7 +27,7 @@ type ManualTriggers struct { // NewManualTriggerCapabilities creates and registers cron and HTTP trigger capabilities. // These are chain-agnostic and shared across all chain types. -func NewManualTriggerCapabilities(ctx context.Context, lggr logger.Logger, registry *capabilities.Registry) (*ManualTriggers, error) { +func NewManualTriggerCapabilities(ctx context.Context, lggr logger.Logger, registry *capabilities.Registry, httpTriggerPort int, limits *SimulationLimits) (*ManualTriggers, error) { manualCronTrigger, err := fakes.NewManualCronTriggerService(lggr) if err != nil { return nil, err @@ -40,7 +37,12 @@ func NewManualTriggerCapabilities(ctx context.Context, lggr logger.Logger, regis return nil, err } - manualHTTPTrigger := NewManualHTTPTriggerService(lggr) + var httpTriggerRateLimit *config.Rate + if limits != nil { + rate := limits.HTTPTriggerRateLimit() + httpTriggerRateLimit = &rate + } + manualHTTPTrigger := NewManualHTTPTriggerService(lggr, httpTriggerPort, httpTriggerRateLimit) manualHTTPTriggerServer := httptrigger.NewHTTPServer(manualHTTPTrigger) if err := registry.Add(ctx, manualHTTPTriggerServer); err != nil { return nil, err @@ -122,7 +124,7 @@ func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry confHTTPAction := fakes.NewDirectConfidentialHTTPAction(lggr, secretsPath) var confHTTPCap confhttpserver.ClientCapability = confHTTPAction if limits != nil { - confHTTPCap = NewLimitedConfidentialHTTPAction(confHTTPAction, limits) + confHTTPCap = NewLimitedConfidentialHTTPAction(confHTTPAction, limits, lggr) } confHTTPActionServer := confhttpserver.NewClientServer(confHTTPCap) if err := registry.Add(ctx, confHTTPActionServer); err != nil { diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go index 756b94ca..89737149 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -15,6 +15,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/services" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + crpc "github.com/smartcontractkit/cre-cli/internal/rpc" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/ui" ) @@ -53,7 +54,7 @@ func (ct *AptosChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, ct.log.Debug().Msgf("RPC not provided for %s; skipping", name) continue } - ct.log.Debug().Msgf("Using RPC for %s: %s", name, chain.RedactURL(rpcURL)) + ct.log.Debug().Msgf("Using RPC for %s: %s", name, crpc.RedactURL(rpcURL)) client, err := aptos.NewClient(aptos.NetworkConfig{NodeUrl: rpcURL}) if err != nil { ui.Warning(fmt.Sprintf("Failed to build Aptos client for %s: %v", name, err)) @@ -93,7 +94,7 @@ func (ct *AptosChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, } continue } - ct.log.Debug().Msgf("Using RPC for experimental aptos chain %d: %s", ec.ChainSelector, chain.RedactURL(ec.RPCURL)) + ct.log.Debug().Msgf("Using RPC for experimental aptos chain %d: %s", ec.ChainSelector, crpc.RedactURL(ec.RPCURL)) client, err := aptos.NewClient(aptos.NetworkConfig{NodeUrl: ec.RPCURL}) if err != nil { return chain.ResolvedChains{}, fmt.Errorf("failed to create aptos client for experimental chain %d: %w", ec.ChainSelector, err) diff --git a/cmd/workflow/simulate/chain/evm/chaintype.go b/cmd/workflow/simulate/chain/evm/chaintype.go index 3c8176d9..04c1f4b1 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype.go +++ b/cmd/workflow/simulate/chain/evm/chaintype.go @@ -17,9 +17,12 @@ import ( corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" evmpb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + "github.com/smartcontractkit/chainlink-common/pkg/config" "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/rpc" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/ui" ) @@ -69,7 +72,7 @@ func (ct *EVMChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, er ct.log.Debug().Msgf("RPC not provided for %s; skipping", chainName) continue } - ct.log.Debug().Msgf("Using RPC for %s: %s", chainName, chain.RedactURL(rpcURL)) + ct.log.Debug().Msgf("Using RPC for %s: %s", chainName, rpc.RedactURL(rpcURL)) c, err := ethclient.Dial(rpcURL) if err != nil { @@ -116,7 +119,7 @@ func (ct *EVMChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, er continue } - ct.log.Debug().Msgf("Using RPC for experimental chain %d: %s", ec.ChainSelector, chain.RedactURL(ec.RPCURL)) + ct.log.Debug().Msgf("Using RPC for experimental chain %d: %s", ec.ChainSelector, rpc.RedactURL(ec.RPCURL)) c, err := ethclient.Dial(ec.RPCURL) if err != nil { return chain.ResolvedChains{}, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) @@ -212,15 +215,17 @@ func (ct *EVMChainType) NewTriggerListener(ctx context.Context, selector uint64, if strings.TrimSpace(params.ChainTypeInputs[TriggerInputTxHash]) != "" { return nil, fmt.Errorf("--listen cannot be combined with --%s for EVM log triggers", TriggerInputTxHash) } + eventRateLimit := eventRateLimitFromLimits(params.Limits) cfg, err := decodeLogTriggerConfig(params.TriggerPayload) if err != nil { return nil, fmt.Errorf("failed to decode EVM log trigger config: %w", err) } return NewEVMLogTriggerListener(ctx, client, WaitForLogConfig{ - Selector: selector, - Filter: cfg, - WorkflowName: params.WorkflowName, + Selector: selector, + Filter: cfg, + WorkflowName: params.WorkflowName, + EventRateLimit: eventRateLimit, }) } @@ -248,7 +253,7 @@ func (ct *EVMChainType) ResolveKey(creSettings *settings.Settings, broadcast boo if err != nil { // If the user explicitly set a key that looks like a hex string but is // malformed (wrong length, invalid chars), always error with guidance. - // Skip placeholder values like "your-eth-private-key" from the default .env template. + // Skip placeholder values like DefaultEthPrivateKeyEnvPlaceholder from the default .env template. evmKey := creSettings.User.PrivateKey(settings.EVM) if evmKey != "" && isHexString(evmKey) { return nil, fmt.Errorf( @@ -360,8 +365,17 @@ func (ct *EVMChainType) ResolveTriggerData(ctx context.Context, selector uint64, return nil, fmt.Errorf("failed to decode EVM log trigger config: %w", err) } return WaitForEVMTriggerLog(ctx, client, WaitForLogConfig{ - Selector: selector, - Filter: cfg, - WorkflowName: params.WorkflowName, + Selector: selector, + Filter: cfg, + WorkflowName: params.WorkflowName, + EventRateLimit: eventRateLimitFromLimits(params.Limits), }) } + +func eventRateLimitFromLimits(limits *cresettings.Workflows) *config.Rate { + if limits == nil { + return nil + } + rate := limits.LogTrigger.EventRateLimit.DefaultValue + return &rate +} diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities.go b/cmd/workflow/simulate/chain/evm/limited_capabilities.go index f46e3282..1928c857 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities.go @@ -29,14 +29,14 @@ func NewLimitedEVMChain(inner evmserver.ClientCapability, limits chain.Limits) * func (l *LimitedEVMChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { if l.limits.ReportSize > 0 && input.Report != nil && len(input.Report.RawReport) > l.limits.ReportSize { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: chain write report size %d bytes exceeds limit of %d bytes", len(input.Report.RawReport), l.limits.ReportSize), + fmt.Errorf("EVM chain write report of %d bytes exceeds the simulation limit of %d bytes. This limit mirrors a production constraint.\nReduce the report size written to chain. Use 'cre workflow limits export' to customize limits, or --limits=none to disable", len(input.Report.RawReport), l.limits.ReportSize), caperrors.ResourceExhausted, ) } if l.limits.GasLimit > 0 && input.GasConfig != nil && input.GasConfig.GasLimit > l.limits.GasLimit { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: EVM gas limit %d exceeds maximum of %d", input.GasConfig.GasLimit, l.limits.GasLimit), + fmt.Errorf("EVM gas of %d gas units exceeds the simulation limit of %d gas units. This limit mirrors a production constraint.\nReduce gas_config.gas_limit in your chain write step. Use 'cre workflow limits export' to customize limits, or --limits=none to disable", input.GasConfig.GasLimit, l.limits.GasLimit), caperrors.ResourceExhausted, ) } diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go index 8b3eb916..9d085c9d 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go @@ -97,7 +97,7 @@ func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { }) require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "chain write report size 5 bytes exceeds limit of 4 bytes") + assert.Contains(t, err.Error(), "EVM chain write report of 5 bytes exceeds the simulation limit of 4 bytes") assert.Equal(t, 0, inner.writeReportCalls) } @@ -113,7 +113,7 @@ func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { }) require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "EVM gas limit 11 exceeds maximum of 10") + assert.Contains(t, err.Error(), "EVM gas of 11 gas units exceeds the simulation limit of 10 gas units") assert.Equal(t, 0, inner.writeReportCalls) } diff --git a/cmd/workflow/simulate/chain/evm/supported_chains.go b/cmd/workflow/simulate/chain/evm/supported_chains.go index dde4d4fe..db53fdab 100644 --- a/cmd/workflow/simulate/chain/evm/supported_chains.go +++ b/cmd/workflow/simulate/chain/evm/supported_chains.go @@ -134,6 +134,7 @@ var SupportedChains = []chain.ChainConfig{ // DTCC {Selector: chainselectors.DTCC_TESTNET_ANDESITE.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.DTCC_MAINNET_APPCHAIN.Selector, Forwarder: "0xBefF2190E6F56C108cD748844Bbd18D4a70F1E21"}, // ADI {Selector: chainselectors.ADI_TESTNET.Selector, Forwarder: "0x9eF6468C5f37b976E57d52054c693269479A784d"}, @@ -141,4 +142,10 @@ var SupportedChains = []chain.ChainConfig{ // Rhyolite (private testnet) {Selector: chainselectors.PRIVATE_TESTNET_RHYOLITE.Selector, Forwarder: "0xBefF2190E6F56C108cD748844Bbd18D4a70F1E21"}, + + // Pumice (private testnet) + {Selector: chainselectors.PRIVATE_TESTNET_PUMICE.Selector, Forwarder: "0xBefF2190E6F56C108cD748844Bbd18D4a70F1E21"}, + + // Quartzite (private testnet) + {Selector: chainselectors.PRIVATE_TESTNET_QUARTZITE.Selector, Forwarder: "0xBefF2190E6F56C108cD748844Bbd18D4a70F1E21"}, } diff --git a/cmd/workflow/simulate/chain/evm/trigger.go b/cmd/workflow/simulate/chain/evm/trigger.go index 95c641fa..5e6d9e06 100644 --- a/cmd/workflow/simulate/chain/evm/trigger.go +++ b/cmd/workflow/simulate/chain/evm/trigger.go @@ -13,9 +13,11 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "golang.org/x/time/rate" "google.golang.org/protobuf/types/known/anypb" evmpb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + "github.com/smartcontractkit/chainlink-common/pkg/config" valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" "github.com/smartcontractkit/cre-cli/internal/settings" @@ -41,6 +43,8 @@ type WaitForLogConfig struct { Selector uint64 Filter *evmpb.FilterLogTriggerRequest WorkflowName string + // EventRateLimit enforces LogTrigger.EventRateLimit when set. + EventRateLimit *config.Rate // PollInterval overrides the polling cadence for tests. Zero means default. PollInterval time.Duration // NowBlock overrides the initial "latest block" lookup for tests. When nil, @@ -56,6 +60,8 @@ type EVMLogTriggerListener struct { fromBlock *big.Int seen map[string]struct{} heartbeat int + limiter *rate.Limiter + limitText string } func NewEVMLogTriggerListener(ctx context.Context, ethClient *ethclient.Client, cfg WaitForLogConfig) (*EVMLogTriggerListener, error) { @@ -88,6 +94,13 @@ func NewEVMLogTriggerListener(ctx context.Context, ethClient *ethclient.Client, } ui.Dim(fmt.Sprintf("Listening for logs starting at block %s...", fromBlock.String())) + var eventLimiter *rate.Limiter + limitText := "" + if cfg.EventRateLimit != nil && cfg.EventRateLimit.Limit > 0 && cfg.EventRateLimit.Burst > 0 { + eventLimiter = rate.NewLimiter(cfg.EventRateLimit.Limit, cfg.EventRateLimit.Burst) + limitText = cfg.EventRateLimit.String() + } + return &EVMLogTriggerListener{ ethClient: ethClient, addresses: addresses, @@ -95,6 +108,8 @@ func NewEVMLogTriggerListener(ctx context.Context, ethClient *ethclient.Client, poll: poll, fromBlock: fromBlock, seen: make(map[string]struct{}), + limiter: eventLimiter, + limitText: limitText, }, nil } @@ -148,6 +163,12 @@ func (l *EVMLogTriggerListener) scanOnce(ctx context.Context) (*types.Log, error if _, ok := l.seen[key]; ok { continue } + if l.limiter != nil && !l.limiter.Allow() { + ui.Warning(fmt.Sprintf("Log trigger event rate limit exceeded (%s) — event dropped (tx %s, index %d)", + l.limitText, logs[i].TxHash.Hex(), logs[i].Index)) + l.seen[key] = struct{}{} + continue + } l.seen[key] = struct{}{} return &logs[i], nil } diff --git a/cmd/workflow/simulate/chain/evm/trigger_test.go b/cmd/workflow/simulate/chain/evm/trigger_test.go index 032a004d..eeee2437 100644 --- a/cmd/workflow/simulate/chain/evm/trigger_test.go +++ b/cmd/workflow/simulate/chain/evm/trigger_test.go @@ -3,6 +3,7 @@ package evm import ( "context" "encoding/json" + "errors" "fmt" "math/big" "net/http" @@ -16,9 +17,11 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/time/rate" "google.golang.org/protobuf/types/known/anypb" evmpb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + "github.com/smartcontractkit/chainlink-common/pkg/config" valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" ) @@ -628,6 +631,69 @@ func TestEVMLogTriggerListenerReturnsMultipleMatchingLogs(t *testing.T) { assert.Equal(t, uint32(1), secondLog.Index) } +func TestEVMLogTriggerListenerEnforcesEventRateLimit(t *testing.T) { + t.Parallel() + + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + addr := addrFromHex("0xabcd000000000000000000000000000000000006") + sig := hashFromHex("0x" + strings.Repeat("e", 64)) + + m.mu.Lock() + m.headNumber = 400 + m.logs = []*types.Log{ + { + Address: addr, + Topics: []common.Hash{sig}, + Data: []byte{0x01}, + BlockHash: hashFromHex("0xbe"), + TxHash: hashFromHex("0x" + strings.Repeat("3", 64)), + BlockNumber: 400, + TxIndex: 0, + Index: 0, + }, + { + Address: addr, + Topics: []common.Hash{sig}, + Data: []byte{0x02}, + BlockHash: hashFromHex("0xbe"), + TxHash: hashFromHex("0x" + strings.Repeat("4", 64)), + BlockNumber: 400, + TxIndex: 1, + Index: 1, + }, + } + m.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + listener, err := NewEVMLogTriggerListener(ctx, c, WaitForLogConfig{ + Selector: 16015286601757825753, + Filter: &evmpb.FilterLogTriggerRequest{ + Addresses: [][]byte{addr.Bytes()}, + Topics: []*evmpb.TopicValues{{Values: [][]byte{sig.Bytes()}}}, + }, + PollInterval: time.Second, + EventRateLimit: &config.Rate{ + Limit: rate.Every(time.Hour), + Burst: 1, + }, + }) + require.NoError(t, err) + + _, err = listener.Next(ctx) + require.NoError(t, err) + + // The second log is rate-limited: it should be dropped silently and the + // listener should keep waiting rather than returning an error. The context + // times out because no further logs arrive. + _, err = listener.Next(ctx) + assert.True(t, errors.Is(err, context.DeadlineExceeded), "expected deadline exceeded after rate-limited log is dropped, got: %v", err) +} + func TestManualEVMTriggerEventsHaveUniqueIDs(t *testing.T) { t.Parallel() diff --git a/cmd/workflow/simulate/chain/solana/capabilities.go b/cmd/workflow/simulate/chain/solana/capabilities.go new file mode 100644 index 00000000..7d8bfa91 --- /dev/null +++ b/cmd/workflow/simulate/chain/solana/capabilities.go @@ -0,0 +1,80 @@ +package solana + +import ( + "context" + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + + solanaserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana/server" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + solanafakes "github.com/smartcontractkit/chainlink-solana/contracts/capabilities/fakes" + "github.com/smartcontractkit/chainlink/v2/core/capabilities" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +// SolanaChainCapabilities holds the per-selector FakeSolanaChain instances +// created for simulation. +type SolanaChainCapabilities struct { + SolanaChains map[uint64]*solanafakes.FakeSolanaChain +} + +// NewSolanaChainCapabilities builds FakeSolanaChain instances for every +// (selector -> client) pair, wraps them with LimitedSolanaChain, and registers +// each with the capability registry. +func NewSolanaChainCapabilities( + ctx context.Context, + lggr logger.Logger, + registry *capabilities.Registry, + clients map[uint64]*rpc.Client, + forwarderProgramIDs map[uint64]solana.PublicKey, + forwarderStateAccounts map[uint64]solana.PublicKey, + transmitter solana.PrivateKey, + dryRunChainWrite bool, + limits chain.Limits, +) (*SolanaChainCapabilities, error) { + chains := make(map[uint64]*solanafakes.FakeSolanaChain) + for sel, client := range clients { + programID, ok := forwarderProgramIDs[sel] + if !ok { + lggr.Infow("Forwarder program ID not found for chain", "selector", sel) + continue + } + stateAccount, ok := forwarderStateAccounts[sel] + if !ok { + lggr.Infow("Forwarder state account not found for chain", "selector", sel) + continue + } + fc, err := solanafakes.NewFakeSolanaChain(lggr, client, transmitter, programID, stateAccount, sel, dryRunChainWrite) + if err != nil { + return nil, fmt.Errorf("new FakeSolanaChain for selector %d: %w", sel, err) + } + capability := NewLimitedSolanaChain(fc, limits) + server := solanaserver.NewClientServer(capability) + if err := registry.Add(ctx, server); err != nil { + return nil, fmt.Errorf("register solana capability for selector %d: %w", sel, err) + } + chains[sel] = fc + } + return &SolanaChainCapabilities{SolanaChains: chains}, nil +} + +func (c *SolanaChainCapabilities) Start(ctx context.Context) error { + for _, fc := range c.SolanaChains { + if err := fc.Start(ctx); err != nil { + return err + } + } + return nil +} + +func (c *SolanaChainCapabilities) Close() error { + for _, fc := range c.SolanaChains { + if err := fc.Close(); err != nil { + return err + } + } + return nil +} diff --git a/cmd/workflow/simulate/chain/solana/chaintype.go b/cmd/workflow/simulate/chain/solana/chaintype.go new file mode 100644 index 00000000..45fc1135 --- /dev/null +++ b/cmd/workflow/simulate/chain/solana/chaintype.go @@ -0,0 +1,189 @@ +package solana + +import ( + "context" + "fmt" + "strings" + + "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" + "github.com/rs/zerolog" + "github.com/spf13/viper" + + corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" + "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + crpc "github.com/smartcontractkit/cre-cli/internal/rpc" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func init() { + chain.Register(string(corekeys.Solana), func(lggr *zerolog.Logger) chain.ChainType { + return &SolanaChainType{log: lggr} + }, nil) +} + +// SolanaChainType implements chain.ChainType for Solana. +type SolanaChainType struct { + log *zerolog.Logger + solanaChains *SolanaChainCapabilities + programIDs map[uint64]solana.PublicKey + stateAccounts map[uint64]solana.PublicKey +} + +var _ chain.ChainType = (*SolanaChainType)(nil) + +func (ct *SolanaChainType) Name() string { return string(corekeys.Solana) } +func (ct *SolanaChainType) SupportedChains() []chain.ChainConfig { return SupportedChains } + +func (ct *SolanaChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, error) { + clients := make(map[uint64]chain.ChainClient) + forwarders := make(map[uint64]string) + ct.programIDs = make(map[uint64]solana.PublicKey) + ct.stateAccounts = make(map[uint64]solana.PublicKey) + + for _, c := range SupportedChains { + name, err := settings.GetChainNameByChainSelector(c.Selector) + if err != nil { + ct.log.Error().Msgf("Invalid Solana chain selector %d; skipping", c.Selector) + continue + } + rpcURL, err := settings.GetRpcUrlSettings(v, name) + if err != nil || strings.TrimSpace(rpcURL) == "" { + ct.log.Debug().Msgf("RPC not provided for %s; skipping", name) + continue + } + ct.log.Debug().Msgf("Using RPC for %s: %s", name, crpc.RedactURL(rpcURL)) + + programID, err := solana.PublicKeyFromBase58(c.Forwarder) + if err != nil { + return chain.ResolvedChains{}, fmt.Errorf("invalid forwarder program ID for %s: %w", name, err) + } + stateB58, ok := forwarderStateAccounts[c.Selector] + if !ok || strings.TrimSpace(stateB58) == "" { + return chain.ResolvedChains{}, fmt.Errorf("no forwarder state account configured for %s", name) + } + state, err := solana.PublicKeyFromBase58(stateB58) + if err != nil { + return chain.ResolvedChains{}, fmt.Errorf("invalid forwarder state account for %s: %w", name, err) + } + + clients[c.Selector] = solanarpc.New(rpcURL) + forwarders[c.Selector] = c.Forwarder + ct.programIDs[c.Selector] = programID + ct.stateAccounts[c.Selector] = state + } + + return chain.ResolvedChains{Clients: clients, Forwarders: forwarders}, nil +} + +func (ct *SolanaChainType) ResolveKey(s *settings.Settings, broadcast bool) (interface{}, error) { + raw := strings.TrimSpace(s.User.PrivateKey(settings.Solana)) + + // Solana simulation requires a valid private key in all cases (both broadcast and non-broadcast). + // Unlike EVM (which uses Anvil with pre-funded deterministic accounts), Solana's test network + // requires the transmitter account to exist and be funded on-chain. Using a random or sentinel key + // will fail when the RPC tries to access a non-existent signer account. + // Solution: Mandate CRE_SOLANA_PRIVATE_KEY for all Solana workflow simulations. + if raw == "" { + return nil, fmt.Errorf( + "CRE_SOLANA_PRIVATE_KEY is required for Solana workflow simulation.\n\n" + + "The Solana test network requires the transmitter account (derived from your private key) to exist and be funded on-chain.\n" + + "Please set your private key in your .env file or system environment:\n\n" + + " CRE_SOLANA_PRIVATE_KEY=\n\n" + + "You can generate a test key using: solana-keygen new\n" + + "Then fund it on devnet: solana airdrop 10 --url devnet", + ) + } + + // Try base58 (64-byte solana keypair, standard Solana CLI / wallet format). + if key, err := solana.PrivateKeyFromBase58(raw); err == nil && len(key) == 64 { + if broadcast && key.PublicKey().IsZero() { + return nil, fmt.Errorf("CRE_SOLANA_PRIVATE_KEY decodes to a zero key; refusing to broadcast") + } + return key, nil + } + + return nil, fmt.Errorf("CRE_SOLANA_PRIVATE_KEY must be a 64-byte base58 keypair") +} + +func (ct *SolanaChainType) ResolveTriggerData(_ context.Context, _ uint64, _ chain.TriggerParams) (interface{}, error) { + return nil, fmt.Errorf("solana: no trigger surface") +} + +func (ct *SolanaChainType) RegisterCapabilities(ctx context.Context, cfg chain.CapabilityConfig) ([]services.Service, error) { + typedClients := make(map[uint64]*solanarpc.Client, len(cfg.Clients)) + for sel, c := range cfg.Clients { + sc, ok := c.(*solanarpc.Client) + if !ok { + return nil, fmt.Errorf("solana: client for selector %d is not *rpc.Client", sel) + } + typedClients[sel] = sc + } + var key solana.PrivateKey + if cfg.PrivateKey != nil { + var ok bool + key, ok = cfg.PrivateKey.(solana.PrivateKey) + if !ok { + return nil, fmt.Errorf("solana: private key is not solana.PrivateKey") + } + } + var lim chain.Limits + if cfg.Limits != nil { + lim = ExtractLimits(cfg.Limits) + } + caps, err := NewSolanaChainCapabilities( + ctx, cfg.Logger, cfg.Registry, + typedClients, + ct.programIDs, + ct.stateAccounts, + key, + !cfg.Broadcast, + lim, + ) + if err != nil { + return nil, err + } + if err := caps.Start(ctx); err != nil { + return nil, fmt.Errorf("solana: failed to start: %w", err) + } + ct.solanaChains = caps + out := make([]services.Service, 0, len(caps.SolanaChains)) + for _, fc := range caps.SolanaChains { + out = append(out, fc) + } + return out, nil +} + +func (ct *SolanaChainType) ExecuteTrigger(_ context.Context, _ uint64, _ string, _ interface{}) error { + return fmt.Errorf("solana: no trigger surface") +} + +func (ct *SolanaChainType) Supports(selector uint64) bool { + if ct.solanaChains == nil { + return false + } + return ct.solanaChains.SolanaChains[selector] != nil +} + +func (ct *SolanaChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { + return chain.ParseTriggerChainSelector(ct.Name(), triggerID) +} + +func (ct *SolanaChainType) RunHealthCheck(resolved chain.ResolvedChains) error { + return RunRPCHealthCheck(resolved.Clients, resolved.ExperimentalSelectors) +} + +func (ct *SolanaChainType) CollectCLIInputs(_ *viper.Viper) map[string]string { + return map[string]string{} +} + +func ExtractLimits(w *cresettings.Workflows) chain.Limits { + return chain.Limits{ + ReportSize: int(w.ChainWrite.Solana.ReportSizeLimit.DefaultValue), + // Solana compute-unit limit is Setting[uint32]; widen to chain.Limits.GasLimit (uint64). + GasLimit: uint64(w.ChainWrite.Solana.GasLimit.Default.DefaultValue), + } +} diff --git a/cmd/workflow/simulate/chain/solana/health.go b/cmd/workflow/simulate/chain/solana/health.go new file mode 100644 index 00000000..4ab3939d --- /dev/null +++ b/cmd/workflow/simulate/chain/solana/health.go @@ -0,0 +1,57 @@ +package solana + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/gagliardetto/solana-go/rpc" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +const healthCheckTimeout = 5 * time.Second + +// RunRPCHealthCheck probes GetHealth() on every configured Solana client. +// experimentalSelectors identifies chains sourced from experimental-chains config. +func RunRPCHealthCheck(clients map[uint64]chain.ChainClient, experimentalSelectors map[uint64]bool) error { + if len(clients) == 0 { + return fmt.Errorf("check your settings: no Solana RPC URLs found for supported or experimental chains") + } + var errs []error + for sel, c := range clients { + if c == nil { + errs = append(errs, fmt.Errorf("[%d] nil client", sel)) + continue + } + sc, ok := c.(*rpc.Client) + if !ok { + errs = append(errs, fmt.Errorf("[%d] invalid client type for Solana chain type", sel)) + continue + } + label := selectorLabel(sel, experimentalSelectors) + + ctx, cancel := context.WithTimeout(context.Background(), healthCheckTimeout) + // GetHealth returns "ok" on healthy nodes and an error otherwise. + if _, err := sc.GetHealth(ctx); err != nil { + errs = append(errs, fmt.Errorf("[%s] failed RPC health check: %w", label, err)) + } + cancel() + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +func selectorLabel(sel uint64, experimentalSelectors map[uint64]bool) string { + if experimentalSelectors[sel] { + return fmt.Sprintf("experimental chain %d", sel) + } + if name, err := settings.GetChainNameByChainSelector(sel); err == nil { + return name + } + return fmt.Sprintf("chain %d", sel) +} diff --git a/cmd/workflow/simulate/chain/solana/limited_capabilities.go b/cmd/workflow/simulate/chain/solana/limited_capabilities.go new file mode 100644 index 00000000..a41c7dab --- /dev/null +++ b/cmd/workflow/simulate/chain/solana/limited_capabilities.go @@ -0,0 +1,101 @@ +package solana + +import ( + "context" + "fmt" + + commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" + solcap "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana" + solanaserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana/server" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +// LimitedSolanaChain enforces chain-write report size + Solana compute-unit limit. +type LimitedSolanaChain struct { + inner solanaserver.ClientCapability + limits chain.Limits +} + +var _ solanaserver.ClientCapability = (*LimitedSolanaChain)(nil) + +func NewLimitedSolanaChain(inner solanaserver.ClientCapability, limits chain.Limits) *LimitedSolanaChain { + return &LimitedSolanaChain{inner: inner, limits: limits} +} + +func (l *LimitedSolanaChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *solcap.WriteReportRequest) (*commonCap.ResponseAndMetadata[*solcap.WriteReportReply], caperrors.Error) { + if input != nil && input.Report != nil { + if lim := l.limits.ReportSize; lim > 0 && len(input.Report.RawReport) > lim { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: Solana chain write report size %d bytes exceeds limit of %d bytes", len(input.Report.RawReport), lim), + caperrors.ResourceExhausted, + ) + } + } + if input != nil && input.ComputeConfig != nil { + if gl := l.limits.GasLimit; gl > 0 && uint64(input.ComputeConfig.ComputeLimit) > gl { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: Solana compute_limit %d exceeds maximum of %d", input.ComputeConfig.ComputeLimit, gl), + caperrors.ResourceExhausted, + ) + } + } + return l.inner.WriteReport(ctx, metadata, input) +} + +// --- Reads: delegate --- + +func (l *LimitedSolanaChain) GetAccountInfoWithOpts(ctx context.Context, m commonCap.RequestMetadata, i *solcap.GetAccountInfoWithOptsRequest) (*commonCap.ResponseAndMetadata[*solcap.GetAccountInfoWithOptsReply], caperrors.Error) { + return l.inner.GetAccountInfoWithOpts(ctx, m, i) +} +func (l *LimitedSolanaChain) GetBalance(ctx context.Context, m commonCap.RequestMetadata, i *solcap.GetBalanceRequest) (*commonCap.ResponseAndMetadata[*solcap.GetBalanceReply], caperrors.Error) { + return l.inner.GetBalance(ctx, m, i) +} +func (l *LimitedSolanaChain) GetBlock(ctx context.Context, m commonCap.RequestMetadata, i *solcap.GetBlockRequest) (*commonCap.ResponseAndMetadata[*solcap.GetBlockReply], caperrors.Error) { + return l.inner.GetBlock(ctx, m, i) +} +func (l *LimitedSolanaChain) GetFeeForMessage(ctx context.Context, m commonCap.RequestMetadata, i *solcap.GetFeeForMessageRequest) (*commonCap.ResponseAndMetadata[*solcap.GetFeeForMessageReply], caperrors.Error) { + return l.inner.GetFeeForMessage(ctx, m, i) +} +func (l *LimitedSolanaChain) GetMultipleAccountsWithOpts(ctx context.Context, m commonCap.RequestMetadata, i *solcap.GetMultipleAccountsWithOptsRequest) (*commonCap.ResponseAndMetadata[*solcap.GetMultipleAccountsWithOptsReply], caperrors.Error) { + return l.inner.GetMultipleAccountsWithOpts(ctx, m, i) +} +func (l *LimitedSolanaChain) GetProgramAccounts(ctx context.Context, m commonCap.RequestMetadata, i *solcap.GetProgramAccountsRequest) (*commonCap.ResponseAndMetadata[*solcap.GetProgramAccountsReply], caperrors.Error) { + return l.inner.GetProgramAccounts(ctx, m, i) +} +func (l *LimitedSolanaChain) GetSignatureStatuses(ctx context.Context, m commonCap.RequestMetadata, i *solcap.GetSignatureStatusesRequest) (*commonCap.ResponseAndMetadata[*solcap.GetSignatureStatusesReply], caperrors.Error) { + return l.inner.GetSignatureStatuses(ctx, m, i) +} +func (l *LimitedSolanaChain) GetSlotHeight(ctx context.Context, m commonCap.RequestMetadata, i *solcap.GetSlotHeightRequest) (*commonCap.ResponseAndMetadata[*solcap.GetSlotHeightReply], caperrors.Error) { + return l.inner.GetSlotHeight(ctx, m, i) +} +func (l *LimitedSolanaChain) GetTransaction(ctx context.Context, m commonCap.RequestMetadata, i *solcap.GetTransactionRequest) (*commonCap.ResponseAndMetadata[*solcap.GetTransactionReply], caperrors.Error) { + return l.inner.GetTransaction(ctx, m, i) +} + +// --- Triggers: delegate --- + +func (l *LimitedSolanaChain) RegisterLogTrigger(ctx context.Context, triggerID string, m commonCap.RequestMetadata, i *solcap.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*solcap.Log], caperrors.Error) { + return l.inner.RegisterLogTrigger(ctx, triggerID, m, i) +} +func (l *LimitedSolanaChain) UnregisterLogTrigger(ctx context.Context, triggerID string, m commonCap.RequestMetadata, i *solcap.FilterLogTriggerRequest) caperrors.Error { + return l.inner.UnregisterLogTrigger(ctx, triggerID, m, i) +} +func (l *LimitedSolanaChain) AckEvent(ctx context.Context, triggerID, eventID, method string) caperrors.Error { + return l.inner.AckEvent(ctx, triggerID, eventID, method) +} + +// --- Lifecycle: delegate --- + +func (l *LimitedSolanaChain) ChainSelector() uint64 { return l.inner.ChainSelector() } +func (l *LimitedSolanaChain) Start(ctx context.Context) error { return l.inner.Start(ctx) } +func (l *LimitedSolanaChain) Close() error { return l.inner.Close() } +func (l *LimitedSolanaChain) HealthReport() map[string]error { return l.inner.HealthReport() } +func (l *LimitedSolanaChain) Name() string { return l.inner.Name() } +func (l *LimitedSolanaChain) Description() string { return l.inner.Description() } +func (l *LimitedSolanaChain) Ready() error { return l.inner.Ready() } +func (l *LimitedSolanaChain) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { + return l.inner.Initialise(ctx, deps) +} diff --git a/cmd/workflow/simulate/chain/solana/supported_chains.go b/cmd/workflow/simulate/chain/solana/supported_chains.go new file mode 100644 index 00000000..46ad7184 --- /dev/null +++ b/cmd/workflow/simulate/chain/solana/supported_chains.go @@ -0,0 +1,32 @@ +package solana + +import ( + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +// mock_forwarder program + state account addresses, deployed per network from +// chainlink-solana/contracts/programs/mock-forwarder. Users can override via +// experimental-chains config (chain-type: solana). +const ( + devnetMockForwarderProgramID = "7kuEAA3mSC1Tz8gQjnvH7bKFda9xSPRRin9SZbH49cNK" + mainnetMockForwarderProgramID = "7kuEAA3mSC1Tz8gQjnvH7bKFda9xSPRRin9SZbH49cNK" +) + +// SupportedChains lists Solana networks cre-cli simulate can target. +// Forwarder field stores the mock_forwarder program ID. The per-selector +// forwarder *state account* is kept in forwarderStateAccounts because +// chain.ChainConfig only carries one address. +var SupportedChains = []chain.ChainConfig{ + {Selector: chainselectors.SOLANA_DEVNET.Selector, Forwarder: devnetMockForwarderProgramID}, + {Selector: chainselectors.SOLANA_MAINNET.Selector, Forwarder: mainnetMockForwarderProgramID}, +} + +// forwarderStateAccounts maps chain selector → mock_forwarder state account. +// Required because the on-chain `report` instruction needs both program ID +// (resolved via chain.ChainConfig.Forwarder) and state account (here). +var forwarderStateAccounts = map[uint64]string{ + chainselectors.SOLANA_DEVNET.Selector: "5Tipz3yhTBdVsDbaBxZkrp7Gjf3brGq5SKkxReefPMP7", + chainselectors.SOLANA_MAINNET.Selector: "jhCjuD4Z3V7HeSUChMRpkRwpw6B9yC63mxDMv8SdLNX", +} diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go index f7c9248b..7620c63e 100644 --- a/cmd/workflow/simulate/chain/types.go +++ b/cmd/workflow/simulate/chain/types.go @@ -55,6 +55,7 @@ type TriggerParams struct { Clients map[uint64]ChainClient Interactive bool Listen bool + Limits *cresettings.Workflows ChainTypeInputs map[string]string // TriggerPayload is the protobuf Any payload from the selected // pb.TriggerSubscription. Chain types unmarshal it into their own diff --git a/cmd/workflow/simulate/chain/utils.go b/cmd/workflow/simulate/chain/utils.go deleted file mode 100644 index d8291766..00000000 --- a/cmd/workflow/simulate/chain/utils.go +++ /dev/null @@ -1,32 +0,0 @@ -package chain - -import ( - "fmt" - "net/url" - "strings" -) - -// RedactURL returns a version of the URL with path segments and query parameters -// masked to avoid leaking secrets that may have been resolved from environment variables. -// For example, "https://rpc.example.com/v1/my-secret-key" becomes "https://rpc.example.com/v1/***". -func RedactURL(rawURL string) string { - u, err := url.Parse(rawURL) - if err != nil { - return "***" - } - // Mask the last path segment (most common location for API keys) - u.Path = strings.TrimRight(u.Path, "/") - if u.Path != "" && u.Path != "/" { - parts := strings.Split(u.Path, "/") - if len(parts) > 1 { - parts[len(parts)-1] = "***" - } - u.RawPath = "" - u.Path = strings.Join(parts, "/") - } - // Remove query params entirely - u.RawQuery = "" - u.Fragment = "" - // Use Opaque to avoid re-encoding the path - return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) -} diff --git a/cmd/workflow/simulate/chain/utils_test.go b/cmd/workflow/simulate/chain/utils_test.go deleted file mode 100644 index 3247e477..00000000 --- a/cmd/workflow/simulate/chain/utils_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package chain - -import ( - "testing" -) - -func TestRedactURL(t *testing.T) { - tests := []struct { - name string - raw string - want string - }{ - { - name: "masks last path segment", - raw: "https://rpc.example.com/v1/my-secret-key", - want: "https://rpc.example.com/v1/***", - }, - { - name: "removes query params", - raw: "https://rpc.example.com/v1/key?token=secret", - want: "https://rpc.example.com/v1/***", - }, - { - name: "single path segment masked", - raw: "https://rpc.example.com/key", - want: "https://rpc.example.com/***", - }, - { - name: "no path", - raw: "https://rpc.example.com", - want: "https://rpc.example.com", - }, - { - name: "invalid URL", - raw: "://bad", - want: "***", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := RedactURL(tt.raw) - if got != tt.want { - t.Errorf("RedactURL(%q) = %q, want %q", tt.raw, got, tt.want) - } - }) - } -} diff --git a/cmd/workflow/simulate/enclave_policy_test.go b/cmd/workflow/simulate/enclave_policy_test.go new file mode 100644 index 00000000..9dd477e9 --- /dev/null +++ b/cmd/workflow/simulate/enclave_policy_test.go @@ -0,0 +1,107 @@ +package simulate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" +) + +func TestWarnIfRejectedByEnclave(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req *confidentialhttp.HTTPRequest + wantWarns int + }{ + { + name: "compliant request", + req: &confidentialhttp.HTTPRequest{Url: "https://api.example.com/v1", Method: "GET"}, + wantWarns: 0, + }, + { + name: "https on port 443 is compliant", + req: &confidentialhttp.HTTPRequest{Url: "https://api.example.com:443/v1", Method: "POST"}, + wantWarns: 0, + }, + { + name: "plain http warns on scheme", + req: &confidentialhttp.HTTPRequest{Url: "http://api.example.com/v1", Method: "GET"}, + wantWarns: 1, + }, + { + name: "localhost mock server warns but is not blocked", + req: &confidentialhttp.HTTPRequest{Url: "http://localhost:8080/mock", Method: "GET"}, + wantWarns: 2, // scheme + port + }, + { + name: "disallowed method warns", + req: &confidentialhttp.HTTPRequest{Url: "https://api.example.com/v1", Method: "TRACE"}, + wantWarns: 1, + }, + { + name: "all three violations", + req: &confidentialhttp.HTTPRequest{Url: "http://api.example.com:8080/v1", Method: "CONNECT"}, + wantWarns: 3, + }, + { + name: "nil request is a no-op", + req: nil, + wantWarns: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + lggr, logs := logger.TestObserved(t, zapcore.WarnLevel) + warnIfRejectedByEnclave(lggr, tt.req) + assert.Equal(t, tt.wantWarns, logs.Len(), "unexpected warning count") + }) + } +} + +func TestTeeRequirementSummary(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tee *sdkpb.Tee + want string + }{ + { + name: "nil", + tee: nil, + want: "TEE", + }, + { + name: "typed with regions", + tee: &sdkpb.Tee{Item: &sdkpb.Tee_TeeTypesAndRegions{TeeTypesAndRegions: &sdkpb.TeeTypesAndRegions{ + TeeTypeAndRegions: []*sdkpb.TeeTypeAndRegions{{ + Type: sdkpb.TeeType_TEE_TYPE_AWS_NITRO, + Regions: []string{"us-west-2"}, + }}, + }}}, + want: "AWS_NITRO (us-west-2)", + }, + { + name: "any region with regions", + tee: &sdkpb.Tee{Item: &sdkpb.Tee_AnyRegions{AnyRegions: &sdkpb.Regions{ + Regions: []string{"us-west-2", "eu-west-1"}, + }}}, + want: "TEE (us-west-2, eu-west-1)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, teeRequirementSummary(tt.tee)) + }) + } +} diff --git a/cmd/workflow/simulate/limit_errors.go b/cmd/workflow/simulate/limit_errors.go new file mode 100644 index 00000000..094a68e2 --- /dev/null +++ b/cmd/workflow/simulate/limit_errors.go @@ -0,0 +1,62 @@ +package simulate + +import "fmt" + +// LimitKind identifies a specific simulation limit type, allowing callers to +// distinguish limit-exceeded failures from other errors via errors.As. +type LimitKind string + +const ( + LimitWASMBinary LimitKind = "wasm_binary_size" + LimitWASMCompressedBinary LimitKind = "wasm_compressed_binary_size" + LimitHTTPRequest LimitKind = "http_request_size" + LimitHTTPResponse LimitKind = "http_response_size" + LimitConfHTTPRequest LimitKind = "confidential_http_request_size" + LimitConfHTTPResponse LimitKind = "confidential_http_response_size" + LimitConsensusObservation LimitKind = "consensus_observation_size" + LimitChainWriteReport LimitKind = "chain_write_report_size" + LimitEVMGas LimitKind = "evm_gas" +) + +// LimitExceededError is a typed error for simulation limit violations. +// Callers can use errors.As to retrieve the Kind and distinguish limit +// failures from other errors without string matching. +type LimitExceededError struct { + Kind LimitKind + Msg string +} + +func (e *LimitExceededError) Error() string { return e.Msg } + +// limitExceeded builds a user-facing error for a byte-size simulation limit +// violation. mirrorsProd should be true when the limit directly maps to a +// production runtime constraint. +func limitExceeded(kind LimitKind, resource string, actual, limit uint64, mirrorsProd bool, remediation string) *LimitExceededError { + prod := " This limit mirrors a production constraint." + if !mirrorsProd { + prod = "" + } + return &LimitExceededError{ + Kind: kind, + Msg: fmt.Sprintf( + "%s of %d bytes exceeds the simulation limit of %d bytes.%s\n%s. Use 'cre workflow limits export' to customize limits, or --limits=none to disable.", + resource, actual, limit, prod, remediation, + ), + } +} + +// limitExceededUnit builds a user-facing error for a non-byte simulation limit +// (e.g., EVM gas units). +func limitExceededUnit(kind LimitKind, resource string, actual, limit uint64, unit string, mirrorsProd bool, remediation string) *LimitExceededError { + prod := " This limit mirrors a production constraint." + if !mirrorsProd { + prod = "" + } + return &LimitExceededError{ + Kind: kind, + Msg: fmt.Sprintf( + "%s of %d %s exceeds the simulation limit of %d %s.%s\n%s. Use 'cre workflow limits export' to customize limits, or --limits=none to disable.", + resource, actual, unit, limit, unit, prod, remediation, + ), + } +} diff --git a/cmd/workflow/simulate/limit_errors_test.go b/cmd/workflow/simulate/limit_errors_test.go new file mode 100644 index 00000000..63fa3443 --- /dev/null +++ b/cmd/workflow/simulate/limit_errors_test.go @@ -0,0 +1,151 @@ +package simulate + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimitExceededMessageFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind LimitKind + resource string + actual uint64 + limit uint64 + mirrorsProd bool + remediation string + wantSubstrs []string + notWant []string + }{ + { + name: "WASM binary", + kind: LimitWASMBinary, + resource: "WASM binary", + actual: 200, + limit: 100, + mirrorsProd: true, + remediation: "Reduce compiled binary size", + wantSubstrs: []string{ + "WASM binary", + "200 bytes", + "100 bytes", + "production", + "cre workflow limits export", + "--limits=none", + "Reduce compiled binary size", + }, + }, + { + name: "HTTP request", + kind: LimitHTTPRequest, + resource: "HTTP request body", + actual: 5000, + limit: 1000, + mirrorsProd: true, + remediation: "Reduce the request payload", + wantSubstrs: []string{ + "HTTP request body", + "5000 bytes", + "1000 bytes", + "production", + "cre workflow limits export", + "--limits=none", + }, + }, + { + name: "consensus observation", + kind: LimitConsensusObservation, + resource: "Consensus observation", + actual: 30000, + limit: 25000, + mirrorsProd: true, + remediation: "Reduce data passed", + wantSubstrs: []string{ + "Consensus observation", + "30000 bytes", + "25000 bytes", + "production", + }, + }, + { + name: "non-prod mirror limit", + kind: LimitChainWriteReport, + resource: "Some limit", + actual: 10, + limit: 5, + mirrorsProd: false, + remediation: "Do something", + wantSubstrs: []string{ + "Some limit", + "10 bytes", + "5 bytes", + }, + notWant: []string{"production"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := limitExceeded(tc.kind, tc.resource, tc.actual, tc.limit, tc.mirrorsProd, tc.remediation) + + require.NotNil(t, err) + assert.Equal(t, tc.kind, err.Kind) + + for _, want := range tc.wantSubstrs { + assert.True(t, strings.Contains(err.Error(), want), + "expected %q to contain %q", err.Error(), want) + } + for _, notWant := range tc.notWant { + assert.False(t, strings.Contains(err.Error(), notWant), + "expected %q NOT to contain %q", err.Error(), notWant) + } + }) + } +} + +func TestLimitExceededUnitMessageFormat(t *testing.T) { + t.Parallel() + + err := limitExceededUnit(LimitEVMGas, "EVM gas", 6_000_000, 5_000_000, "gas units", true, "Reduce gas_config.gas_limit") + + require.NotNil(t, err) + assert.Equal(t, LimitEVMGas, err.Kind) + assert.Contains(t, err.Error(), "EVM gas") + assert.Contains(t, err.Error(), "6000000 gas units") + assert.Contains(t, err.Error(), "5000000 gas units") + assert.Contains(t, err.Error(), "production") + assert.Contains(t, err.Error(), "cre workflow limits export") + assert.Contains(t, err.Error(), "--limits=none") +} + +func TestLimitExceededErrorsAs(t *testing.T) { + t.Parallel() + + err := limitExceeded(LimitHTTPResponse, "HTTP response body", 200, 100, true, "Filter the response") + + // LimitExceededError is directly usable with errors.As. + var limitErr *LimitExceededError + require.True(t, errors.As(err, &limitErr)) + assert.Equal(t, LimitHTTPResponse, limitErr.Kind) +} + +func TestLimitExceededWrappedErrorsAs(t *testing.T) { + t.Parallel() + + // Verify that wrapping in fmt.Errorf still allows errors.As unwrapping. + inner := limitExceeded(LimitWASMBinary, "WASM binary", 200, 100, true, "Reduce size") + wrapped := errors.New(inner.Error()) // simulate wrapping without %w + + // When wrapped without %w, errors.As won't traverse; verify the direct case works. + var limitErr *LimitExceededError + assert.True(t, errors.As(inner, &limitErr)) + assert.Equal(t, LimitWASMBinary, limitErr.Kind) + _ = wrapped // just to avoid unused variable +} diff --git a/cmd/workflow/simulate/limited_capabilities.go b/cmd/workflow/simulate/limited_capabilities.go index 50441a7e..3a18f46a 100644 --- a/cmd/workflow/simulate/limited_capabilities.go +++ b/cmd/workflow/simulate/limited_capabilities.go @@ -2,7 +2,9 @@ package simulate import ( "context" - "fmt" + "net/url" + "slices" + "strings" "time" "google.golang.org/protobuf/proto" @@ -14,6 +16,7 @@ import ( customhttp "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http" httpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http/server" consensusserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/consensus/server" + "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/types/core" sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" @@ -39,7 +42,8 @@ func (l *LimitedHTTPAction) SendRequest(ctx context.Context, metadata commonCap. reqLimit := l.limits.HTTPRequestSizeLimit() if reqLimit > 0 && len(input.GetBody()) > reqLimit { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: HTTP request body size %d bytes exceeds limit of %d bytes", len(input.GetBody()), reqLimit), + limitExceeded(LimitHTTPRequest, "HTTP request body", uint64(len(input.GetBody())), uint64(reqLimit), true, + "Reduce the request payload in your http_action step"), caperrors.ResourceExhausted, ) } @@ -62,7 +66,8 @@ func (l *LimitedHTTPAction) SendRequest(ctx context.Context, metadata commonCap. respLimit := l.limits.HTTPResponseSizeLimit() if resp != nil && resp.Response != nil && respLimit > 0 && len(resp.Response.GetBody()) > respLimit { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: HTTP response body size %d bytes exceeds limit of %d bytes", len(resp.Response.GetBody()), respLimit), + limitExceeded(LimitHTTPResponse, "HTTP response body", uint64(len(resp.Response.GetBody())), uint64(respLimit), true, + "The upstream returned an oversized response; filter or paginate before consuming"), caperrors.ResourceExhausted, ) } @@ -83,26 +88,74 @@ func (l *LimitedHTTPAction) Initialise(ctx context.Context, deps core.StandardCa // --- LimitedConfidentialHTTPAction --- // LimitedConfidentialHTTPAction wraps a confhttpserver.ClientCapability and enforces -// request/response size limits and connection timeout from SimulationLimits. +// request/response size limits and connection timeout from SimulationLimits. It also +// warns when a request would be rejected by the production TEE enclave's HTTP policy. type LimitedConfidentialHTTPAction struct { inner confhttpserver.ClientCapability limits *SimulationLimits + lggr logger.Logger } var _ confhttpserver.ClientCapability = (*LimitedConfidentialHTTPAction)(nil) -func NewLimitedConfidentialHTTPAction(inner confhttpserver.ClientCapability, limits *SimulationLimits) *LimitedConfidentialHTTPAction { - return &LimitedConfidentialHTTPAction{inner: inner, limits: limits} +func NewLimitedConfidentialHTTPAction(inner confhttpserver.ClientCapability, limits *SimulationLimits, lggr logger.Logger) *LimitedConfidentialHTTPAction { + return &LimitedConfidentialHTTPAction{inner: inner, limits: limits, lggr: lggr} +} + +// Production TEE enclave HTTP policy. These mirror httpfetch.DefaultPolicy() in the +// (private, non-importable) confidential-compute repo: https only, port 443 only, and +// a fixed method allowlist. Kept in sync by hand; update if the enclave policy changes. +var confidentialTEEAllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH"} + +const ( + confidentialTEEAllowedScheme = "https" + confidentialTEEAllowedPort = "443" +) + +// warnIfRejectedByEnclave logs when a confidential HTTP request would be rejected by +// the production TEE enclave policy. It never blocks the request: local simulation +// intentionally allows plain-http and localhost targets (mock servers) that production +// rejects, so developers can still iterate locally. The warning is the fidelity signal. +func warnIfRejectedByEnclave(lggr logger.Logger, req *confidentialhttp.HTTPRequest) { + if lggr == nil || req == nil { + return + } + + if method := strings.ToUpper(strings.TrimSpace(req.GetMethod())); method != "" && !slices.Contains(confidentialTEEAllowedMethods, method) { + lggr.Warnw("Confidential HTTP request would be REJECTED in production: method not allowed by the TEE enclave (permitted here for local simulation only)", + "method", method, "allowedMethods", confidentialTEEAllowedMethods) + } + + raw := strings.TrimSpace(req.GetUrl()) + if raw == "" { + return + } + u, err := url.Parse(raw) + if err != nil { + return + } + if u.Scheme != "" && u.Scheme != confidentialTEEAllowedScheme { + lggr.Warnw("Confidential HTTP request would be REJECTED in production: the TEE enclave allows https only (permitted here for local simulation only)", + "scheme", u.Scheme, "url", raw) + } + if port := u.Port(); port != "" && port != confidentialTEEAllowedPort { + lggr.Warnw("Confidential HTTP request would be REJECTED in production: the TEE enclave allows port 443 only (permitted here for local simulation only)", + "port", port, "url", raw) + } } func (l *LimitedConfidentialHTTPAction) SendRequest(ctx context.Context, metadata commonCap.RequestMetadata, input *confidentialhttp.ConfidentialHTTPRequest) (*commonCap.ResponseAndMetadata[*confidentialhttp.HTTPResponse], caperrors.Error) { + // Warn (do not block) if this request would be rejected by the production TEE enclave. + warnIfRejectedByEnclave(l.lggr, input.GetRequest()) + // Check request size (body string or body bytes) reqLimit := l.limits.ConfHTTPRequestSizeLimit() if reqLimit > 0 && input.GetRequest() != nil { reqSize := len(input.GetRequest().GetBodyString()) + len(input.GetRequest().GetBodyBytes()) if reqSize > reqLimit { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: confidential HTTP request body size %d bytes exceeds limit of %d bytes", reqSize, reqLimit), + limitExceeded(LimitConfHTTPRequest, "Confidential HTTP request body", uint64(reqSize), uint64(reqLimit), true, + "Reduce the payload to the confidential_http_action step"), caperrors.ResourceExhausted, ) } @@ -126,7 +179,8 @@ func (l *LimitedConfidentialHTTPAction) SendRequest(ctx context.Context, metadat respLimit := l.limits.ConfHTTPResponseSizeLimit() if resp != nil && resp.Response != nil && respLimit > 0 && len(resp.Response.GetBody()) > respLimit { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: confidential HTTP response body size %d bytes exceeds limit of %d bytes", len(resp.Response.GetBody()), respLimit), + limitExceeded(LimitConfHTTPResponse, "Confidential HTTP response body", uint64(len(resp.Response.GetBody())), uint64(respLimit), true, + "The upstream returned an oversized response; filter before consuming"), caperrors.ResourceExhausted, ) } @@ -168,7 +222,8 @@ func (l *LimitedConsensusNoDAG) Simple(ctx context.Context, metadata commonCap.R inputSize := proto.Size(input) if inputSize > obsLimit { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: consensus observation size %d bytes exceeds limit of %d bytes", inputSize, obsLimit), + limitExceeded(LimitConsensusObservation, "Consensus observation", uint64(inputSize), uint64(obsLimit), true, //nolint:gosec // proto.Size always returns non-negative + "Reduce data passed as observations to the consensus step"), caperrors.ResourceExhausted, ) } diff --git a/cmd/workflow/simulate/limited_capabilities_test.go b/cmd/workflow/simulate/limited_capabilities_test.go index a927874c..09913c27 100644 --- a/cmd/workflow/simulate/limited_capabilities_test.go +++ b/cmd/workflow/simulate/limited_capabilities_test.go @@ -16,6 +16,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp" customhttp "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http" "github.com/smartcontractkit/chainlink-common/pkg/config" + "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/types/core" sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" @@ -106,7 +107,7 @@ func TestLimitedHTTPActionRejectsOversizedRequest(t *testing.T) { resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &customhttp.Request{Body: []byte("12345")}) require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "HTTP request body size 5 bytes exceeds limit of 4 bytes") + assert.Contains(t, err.Error(), "HTTP request body of 5 bytes exceeds the simulation limit of 4 bytes") assert.Equal(t, 0, inner.sendRequestCalls) } @@ -158,7 +159,7 @@ func TestLimitedHTTPActionRejectsOversizedResponse(t *testing.T) { resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &customhttp.Request{}) require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "HTTP response body size 4 bytes exceeds limit of 3 bytes") + assert.Contains(t, err.Error(), "HTTP response body of 4 bytes exceeds the simulation limit of 3 bytes") assert.Equal(t, 1, inner.sendRequestCalls) } @@ -190,14 +191,14 @@ func TestLimitedConfidentialHTTPActionRejectsOversizedRequest(t *testing.T) { limits.Workflows.ConfidentialHTTP.RequestSizeLimit.DefaultValue = 4 inner := &confidentialHTTPClientCapabilityStub{} - wrapper := NewLimitedConfidentialHTTPAction(inner, limits) + wrapper := NewLimitedConfidentialHTTPAction(inner, limits, logger.Test(t)) resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &confidentialhttp.ConfidentialHTTPRequest{ Request: &confidentialhttp.HTTPRequest{Body: &confidentialhttp.HTTPRequest_BodyString{BodyString: "12345"}}, }) require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "confidential HTTP request body size 5 bytes exceeds limit of 4 bytes") + assert.Contains(t, err.Error(), "Confidential HTTP request body of 5 bytes exceeds the simulation limit of 4 bytes") assert.Equal(t, 0, inner.sendRequestCalls) } @@ -223,7 +224,7 @@ func TestLimitedConfidentialHTTPActionAppliesTimeoutAndAllowsBoundarySizedPayloa }, } - wrapper := NewLimitedConfidentialHTTPAction(inner, limits) + wrapper := NewLimitedConfidentialHTTPAction(inner, limits, logger.Test(t)) resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &confidentialhttp.ConfidentialHTTPRequest{ Request: &confidentialhttp.HTTPRequest{Body: &confidentialhttp.HTTPRequest_BodyBytes{BodyBytes: []byte("1234")}}, }) @@ -247,11 +248,11 @@ func TestLimitedConfidentialHTTPActionRejectsOversizedResponse(t *testing.T) { }, } - wrapper := NewLimitedConfidentialHTTPAction(inner, limits) + wrapper := NewLimitedConfidentialHTTPAction(inner, limits, logger.Test(t)) resp, err := wrapper.SendRequest(context.Background(), commonCap.RequestMetadata{}, &confidentialhttp.ConfidentialHTTPRequest{}) require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "confidential HTTP response body size 4 bytes exceeds limit of 3 bytes") + assert.Contains(t, err.Error(), "Confidential HTTP response body of 4 bytes exceeds the simulation limit of 3 bytes") assert.Equal(t, 1, inner.sendRequestCalls) } @@ -271,7 +272,7 @@ func TestLimitedConsensusNoDAGSimpleRejectsOversizedObservation(t *testing.T) { resp, err := wrapper.Simple(context.Background(), commonCap.RequestMetadata{}, input) require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "consensus observation size") + assert.Contains(t, err.Error(), "Consensus observation of") assert.Equal(t, 0, inner.simpleCalls) } diff --git a/cmd/workflow/simulate/limits.go b/cmd/workflow/simulate/limits.go index bc05bb23..6e826d7d 100644 --- a/cmd/workflow/simulate/limits.go +++ b/cmd/workflow/simulate/limits.go @@ -97,6 +97,8 @@ func applyEngineLimits(cfg *cresettings.Workflows, limits *SimulationLimits) { //ChainWrite limits - NOTE these are not applied here, but allows flexibility in the future if we want engine to control limits cfg.ChainWrite.EVM.ReportSizeLimit = src.ChainWrite.EVM.ReportSizeLimit cfg.ChainWrite.EVM.GasLimit = src.ChainWrite.EVM.GasLimit + cfg.ChainWrite.Solana.ReportSizeLimit = src.ChainWrite.Solana.ReportSizeLimit + cfg.ChainWrite.Solana.GasLimit = src.ChainWrite.Solana.GasLimit cfg.ChainWrite.Aptos.ReportSizeLimit = src.ChainWrite.Aptos.ReportSizeLimit cfg.ChainWrite.Aptos.GasLimit = src.ChainWrite.Aptos.GasLimit @@ -110,6 +112,7 @@ func disableEngineLimits(cfg *cresettings.Workflows) { maxSize := settings.Setting[config.Size]{DefaultValue: math.MaxInt32} maxDuration := settings.Setting[time.Duration]{DefaultValue: 24 * time.Hour} maxGas := settings.Setting[uint64]{DefaultValue: math.MaxUint64} + maxGas32 := settings.Setting[uint32]{DefaultValue: math.MaxUint32} // Execution limits cfg.ExecutionTimeout = maxDuration @@ -160,6 +163,8 @@ func disableEngineLimits(cfg *cresettings.Workflows) { cfg.ChainWrite.TargetsLimit = maxInt cfg.ChainWrite.EVM.ReportSizeLimit = maxSize cfg.ChainWrite.EVM.GasLimit.Default = maxGas + cfg.ChainWrite.Solana.ReportSizeLimit = maxSize + cfg.ChainWrite.Solana.GasLimit.Default = maxGas32 cfg.ChainWrite.Aptos.ReportSizeLimit = maxSize cfg.ChainWrite.Aptos.GasLimit.Default = maxGas @@ -206,7 +211,7 @@ func (l *SimulationLimits) AptosChainWriteReportSizeLimit() int { return int(l.Workflows.ChainWrite.Aptos.ReportSizeLimit.DefaultValue) } -// EVMChainWriteGasLimit returns the default EVM chain write gas limit. +// EVMChainWriteGasLimit returns the default EVM gas limit. func (l *SimulationLimits) EVMChainWriteGasLimit() uint64 { return l.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue } @@ -216,6 +221,26 @@ func (l *SimulationLimits) AptosChainWriteGasLimit() uint64 { return l.Workflows.ChainWrite.Aptos.GasLimit.Default.DefaultValue } +// SolanaChainWriteReportSizeLimit returns the Solana chain write report size limit in bytes. +func (l *SimulationLimits) SolanaChainWriteReportSizeLimit() int { + return int(l.Workflows.ChainWrite.Solana.ReportSizeLimit.DefaultValue) +} + +// SolanaChainWriteComputeLimit returns the default Solana chain write compute unit limit. +func (l *SimulationLimits) SolanaChainWriteComputeLimit() uint32 { + return l.Workflows.ChainWrite.Solana.GasLimit.Default.DefaultValue +} + +// HTTPTriggerRateLimit returns the HTTP trigger event rate limit. +func (l *SimulationLimits) HTTPTriggerRateLimit() config.Rate { + return l.Workflows.HTTPTrigger.RateLimit.DefaultValue +} + +// LogTriggerEventRateLimit returns the log trigger event rate limit. +func (l *SimulationLimits) LogTriggerEventRateLimit() config.Rate { + return l.Workflows.LogTrigger.EventRateLimit.DefaultValue +} + // WASMBinarySize returns the WASM binary size limit in bytes. func (l *SimulationLimits) WASMBinarySize() int { return int(l.Workflows.WASMBinarySizeLimit.DefaultValue) @@ -230,7 +255,7 @@ func (l *SimulationLimits) WASMCompressedBinarySize() int { func (l *SimulationLimits) LimitsSummary() string { w := &l.Workflows return fmt.Sprintf( - "HTTP: req=%s resp=%s timeout=%s | ConfHTTP: req=%s resp=%s timeout=%s | Consensus obs=%s | ChainWrite evm_report=%s evm_gas=%d aptos_report=%s aptos_gas=%d | WASM binary=%s compressed=%s", + "HTTP: req=%s resp=%s timeout=%s | ConfHTTP: req=%s resp=%s timeout=%s | Consensus obs=%s | ChainWrite evm_report=%s evm_gas=%d solana_report=%s solana_cu=%d aptos_report=%s aptos_gas=%d | WASM binary=%s compressed=%s", w.HTTPAction.RequestSizeLimit.DefaultValue, w.HTTPAction.ResponseSizeLimit.DefaultValue, w.HTTPAction.ConnectionTimeout.DefaultValue, @@ -240,6 +265,8 @@ func (l *SimulationLimits) LimitsSummary() string { w.Consensus.ObservationSizeLimit.DefaultValue, w.ChainWrite.EVM.ReportSizeLimit.DefaultValue, w.ChainWrite.EVM.GasLimit.Default.DefaultValue, + w.ChainWrite.Solana.ReportSizeLimit.DefaultValue, + w.ChainWrite.Solana.GasLimit.Default.DefaultValue, w.ChainWrite.Aptos.ReportSizeLimit.DefaultValue, w.ChainWrite.Aptos.GasLimit.Default.DefaultValue, w.WASMBinarySizeLimit.DefaultValue, diff --git a/cmd/workflow/simulate/limits.json b/cmd/workflow/simulate/limits.json index c33b2c1f..ecc281da 100644 --- a/cmd/workflow/simulate/limits.json +++ b/cmd/workflow/simulate/limits.json @@ -32,13 +32,22 @@ }, "ChainWrite": { "TargetsLimit": "10", + "ReportSizeLimit": "50kb", "EVM": { - "ReportSizeLimit": "5kb", + "TransactionGasLimit": "10000000", + "ReportSizeLimit": "50000", "GasLimit": { "Default": "10000000", "Values": {} } }, + "Solana": { + "ReportSizeLimit": "265b", + "GasLimit": { + "Default": "300000", + "Values": {} + } + }, "Aptos": { "ReportSizeLimit": "5kb", "GasLimit": { diff --git a/cmd/workflow/simulate/limits_test.go b/cmd/workflow/simulate/limits_test.go index fcb14faa..1aef37cf 100644 --- a/cmd/workflow/simulate/limits_test.go +++ b/cmd/workflow/simulate/limits_test.go @@ -30,12 +30,16 @@ func TestDefaultLimitsAndExportDefaultLimitsJSON(t *testing.T) { assert.Equal(t, 125_000, limits.ConfHTTPRequestSizeLimit()) assert.Equal(t, 500_000, limits.ConfHTTPResponseSizeLimit()) assert.Equal(t, 25_000, limits.ConsensusObservationSizeLimit()) - assert.Equal(t, 5_000, limits.EVMChainWriteReportSizeLimit()) + assert.Equal(t, 50_000, limits.EVMChainWriteReportSizeLimit()) assert.Equal(t, 5_000, limits.AptosChainWriteReportSizeLimit()) assert.Equal(t, uint64(10_000_000), limits.EVMChainWriteGasLimit()) assert.Equal(t, uint64(2_000_000), limits.AptosChainWriteGasLimit()) assert.Equal(t, 100_000_000, limits.WASMBinarySize()) assert.Equal(t, 20_000_000, limits.WASMCompressedBinarySize()) + assert.InDelta(t, 1.0/30.0, float64(limits.HTTPTriggerRateLimit().Limit), 0.000001) + assert.Equal(t, 1, limits.HTTPTriggerRateLimit().Burst) + assert.InDelta(t, 1.0/6.0, float64(limits.LogTriggerEventRateLimit().Limit), 0.000001) + assert.Equal(t, 10, limits.LogTriggerEventRateLimit().Burst) assert.Equal(t, 50, limits.Workflows.ExecutionConcurrencyLimit.DefaultValue) assert.InDelta(t, 1.0/30.0, float64(limits.Workflows.HTTPTrigger.RateLimit.DefaultValue.Limit), 0.000001) assert.Equal(t, 1, limits.Workflows.HTTPTrigger.RateLimit.DefaultValue.Burst) @@ -51,7 +55,11 @@ func TestLoadLimitsParsesCustomFileAndPreservesDefaultsForUnsetFields(t *testing "ConnectionTimeout": "2s" }, "ChainWrite": { - "EVM": {"ReportSizeLimit": "9kb", "GasLimit": {"Default": "1234567"}}, + "ReportSizeLimit": "9kb", + "EVM": { + "ReportSizeLimit": "9kb", + "GasLimit": {"Default": "1234567"} + }, "Aptos": {"ReportSizeLimit": "11kb", "GasLimit": {"Default": "7654321"}} }, "CRONTrigger": { @@ -63,7 +71,7 @@ func TestLoadLimitsParsesCustomFileAndPreservesDefaultsForUnsetFields(t *testing require.NoError(t, err) assert.Equal(t, 7_000, limits.HTTPRequestSizeLimit()) - assert.Equal(t, 100_000, limits.HTTPResponseSizeLimit(), "unset values should keep embedded defaults") + assert.Equal(t, 100_000, limits.HTTPResponseSizeLimit(), "unset values should keep cresettings defaults") assert.Equal(t, 9_000, limits.EVMChainWriteReportSizeLimit()) assert.Equal(t, 11_000, limits.AptosChainWriteReportSizeLimit()) assert.Equal(t, uint64(1_234_567), limits.EVMChainWriteGasLimit()) @@ -191,6 +199,6 @@ func TestSimulationLimitsSummaryIncludesKeyLimitValues(t *testing.T) { assert.Contains(t, summary, "HTTP: req=120kb resp=250kb timeout=10s") assert.Contains(t, summary, "ConfHTTP: req=125kb resp=500kb timeout=1m30s") assert.Contains(t, summary, "Consensus obs=25kb") - assert.Contains(t, summary, "ChainWrite evm_report=5kb evm_gas=10000000 aptos_report=5kb aptos_gas=2000000") + assert.Contains(t, summary, "ChainWrite evm_report=50kb evm_gas=10000000 solana_report=265b solana_cu=300000 aptos_report=5kb aptos_gas=2000000") assert.Contains(t, summary, "WASM binary=100mb compressed=20mb") } diff --git a/cmd/workflow/simulate/manual_http_trigger.go b/cmd/workflow/simulate/manual_http_trigger.go index 38784319..6140f878 100644 --- a/cmd/workflow/simulate/manual_http_trigger.go +++ b/cmd/workflow/simulate/manual_http_trigger.go @@ -7,16 +7,22 @@ import ( "sync/atomic" "time" + "golang.org/x/time/rate" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" httptypedapi "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http" httpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http/server" + "github.com/smartcontractkit/chainlink-common/pkg/config" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services" "github.com/smartcontractkit/chainlink-common/pkg/types/core" + "github.com/smartcontractkit/chainlink-common/pkg/workflows" "github.com/smartcontractkit/chainlink/v2/core/services/workflows/events" ) +var errHTTPTriggerRateLimited = fmt.Errorf("HTTP trigger rate limited") + var _ services.Service = (*ManualHTTPTriggerService)(nil) var _ httpserver.HTTPCapability = (*ManualHTTPTriggerService)(nil) @@ -38,16 +44,22 @@ type ManualHTTPTriggerService struct { callbackCh map[string]chan capabilities.TriggerAndId[*httptypedapi.Payload] workflowIDs map[string]string inputs map[string]*httptypedapi.Config + limiters map[string]*rate.Limiter eventSeq uint64 + port int + rateLimit *config.Rate } -func NewManualHTTPTriggerService(parentLggr logger.Logger) *ManualHTTPTriggerService { +func NewManualHTTPTriggerService(parentLggr logger.Logger, port int, rateLimit *config.Rate) *ManualHTTPTriggerService { return &ManualHTTPTriggerService{ CapabilityInfo: manualHTTPTriggerInfo, lggr: logger.Named(parentLggr, "HTTPTriggerService"), callbackCh: make(map[string]chan capabilities.TriggerAndId[*httptypedapi.Payload]), workflowIDs: make(map[string]string), inputs: make(map[string]*httptypedapi.Config), + limiters: make(map[string]*rate.Limiter), + port: port, + rateLimit: rateLimit, } } @@ -58,10 +70,19 @@ func (f *ManualHTTPTriggerService) RegisterTrigger(ctx context.Context, triggerI f.inputs[triggerID] = input f.callbackCh[triggerID] = make(chan capabilities.TriggerAndId[*httptypedapi.Payload], 1) f.workflowIDs[triggerID] = metadata.WorkflowID + if f.rateLimit != nil && f.rateLimit.Limit > 0 && f.rateLimit.Burst > 0 { + f.limiters[triggerID] = rate.NewLimiter(f.rateLimit.Limit, f.rateLimit.Burst) + } return f.callbackCh[triggerID], nil } func (f *ManualHTTPTriggerService) UnregisterTrigger(ctx context.Context, triggerID string, metadata capabilities.RequestMetadata, input *httptypedapi.Config) caperrors.Error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.inputs, triggerID) + delete(f.callbackCh, triggerID) + delete(f.workflowIDs, triggerID) + delete(f.limiters, triggerID) return nil } @@ -79,6 +100,7 @@ func (f *ManualHTTPTriggerService) ManualTrigger(ctx context.Context, triggerID workflowID, workflowExists := f.workflowIDs[triggerID] input := f.inputs[triggerID] callbackCh := f.callbackCh[triggerID] + limiter := f.limiters[triggerID] f.mu.RUnlock() if !workflowExists { @@ -92,6 +114,9 @@ func (f *ManualHTTPTriggerService) ManualTrigger(ctx context.Context, triggerID if callbackCh == nil { return fmt.Errorf("callback channel not found for triggerID") } + if limiter != nil && !limiter.Allow() { + return fmt.Errorf("%w: %s", errHTTPTriggerRateLimited, f.rateLimit.String()) + } if payload == nil { var err error @@ -101,8 +126,10 @@ func (f *ManualHTTPTriggerService) ManualTrigger(ctx context.Context, triggerID } } + // triggerIndex defaults to zero in simulation + var triggerIndex int triggerEvent := f.createManualTriggerEvent(payload) - workflowExecutionID, err := events.GenerateExecutionID(workflowID, triggerEvent.Id) + workflowExecutionID, err := workflows.GenerateExecutionIDWithTriggerIndex(workflowID, triggerEvent.Id, triggerIndex) if err != nil { f.lggr.Errorw("failed to generate execution ID", "err", err) workflowExecutionID = "" @@ -121,7 +148,7 @@ func (f *ManualHTTPTriggerService) ManualTrigger(ctx context.Context, triggerID } func (f *ManualHTTPTriggerService) listenForTriggerPayload(ctx context.Context) (*httptypedapi.Payload, error) { - payloadCh, closeServer, err := startHTTPListenPayloadServer(ctx, httpTriggerServerPort) + payloadCh, closeServer, err := startHTTPListenPayloadServer(ctx, f.port) if err != nil { return nil, err } diff --git a/cmd/workflow/simulate/manual_http_trigger_test.go b/cmd/workflow/simulate/manual_http_trigger_test.go new file mode 100644 index 00000000..f09dd4a7 --- /dev/null +++ b/cmd/workflow/simulate/manual_http_trigger_test.go @@ -0,0 +1,41 @@ +package simulate + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + httptypedapi "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http" + "github.com/smartcontractkit/chainlink-common/pkg/config" + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +func TestManualHTTPTriggerEnforcesRateLimit(t *testing.T) { + t.Parallel() + + rateLimit := &config.Rate{ + Limit: rate.Every(time.Hour), + Burst: 1, + } + svc := NewManualHTTPTriggerService(logger.Test(t), defaultHTTPTriggerServerPort, rateLimit) + + _, capErr := svc.RegisterTrigger( + context.Background(), + "trigger-1", + capabilities.RequestMetadata{WorkflowID: "wf-1"}, + &httptypedapi.Config{}, + ) + require.Nil(t, capErr) + + err := svc.ManualTrigger(context.Background(), "trigger-1", &httptypedapi.Payload{Input: []byte(`{"k":"v"}`)}) + require.NoError(t, err) + + err = svc.ManualTrigger(context.Background(), "trigger-1", &httptypedapi.Payload{Input: []byte(`{"k":"v2"}`)}) + require.Error(t, err) + assert.ErrorIs(t, err, errHTTPTriggerRateLimited) +} diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index e5144282..2d82b9e6 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -12,6 +12,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -34,8 +35,9 @@ import ( cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" - _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/aptos" // register Aptos chain family via package init - _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/evm" // register EVM chain family via package init + _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/aptos" // register Aptos chain family via package init + _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/evm" // register EVM chain family via package init + _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/solana" // register Solana chain family via package init "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" @@ -45,6 +47,7 @@ import ( ) const WorkflowExecutionTimeout = 5 * time.Minute +const defaultHTTPTriggerServerPort = 2000 type Inputs struct { WasmPath string `validate:"omitempty,file,ascii,max=97" cli:"--wasm"` @@ -66,6 +69,7 @@ type Inputs struct { HasTriggerIndex bool TriggerIndex int `validate:"-"` HTTPPayload string `validate:"-"` // JSON string or /path/to/file.json + HTTPTriggerPort int `validate:"min=1,max=65535"` ChainTypeInputs map[string]string `validate:"-"` // CLI-supplied chain-type-specific trigger inputs // Listen keeps the HTTP trigger server running after each execution so it can // process additional requests until the user interrupts (ctrl-C). @@ -78,6 +82,11 @@ type Inputs struct { // SetExecutionContext changes it to the workflow directory. Used to resolve file // paths entered interactively or via flags relative to where the user ran the command. InvocationDir string `validate:"-"` + // WorkflowFolderName is the name of the workflow's directory (i.e. the argument + // the user passes to `cre workflow simulate`). Unlike WorkflowName (the Workflow + // Registry name from workflow.yaml, which can differ), this is what should be + // used to render restart-command hints so they match what the user actually typed. + WorkflowFolderName string `validate:"-"` } func New(runtimeContext *runtime.Context) *cobra.Command { @@ -112,7 +121,8 @@ func New(runtimeContext *runtime.Context) *cobra.Command { // Non-interactive trigger selection flags simulateCmd.Flags().Int("trigger-index", -1, "Index of the trigger to run (0-based)") simulateCmd.Flags().String("http-payload", "", "HTTP trigger payload as JSON string or path to JSON file") - simulateCmd.Flags().Bool("listen", false, "Listen for HTTP requests or supported log triggers and run the simulator for each match") + simulateCmd.Flags().Int("http-trigger-port", defaultHTTPTriggerServerPort, "Port used by the local HTTP trigger server") + simulateCmd.Flags().Bool("listen", false, "Listen for HTTP requests or supported log triggers and run the simulator for each match (not supported by cron)") // Register chain-type-specific CLI flags (e.g., --evm-tx-hash). chain.RegisterAllCLIFlags(simulateCmd) @@ -191,26 +201,41 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) } } + httpTriggerPort := v.GetInt("http-trigger-port") + if !v.IsSet("http-trigger-port") { + httpTriggerPort = defaultHTTPTriggerServerPort + } + + // At this point SetExecutionContext has already chdir'd into the workflow + // directory, so its basename is the argument the user passed to + // `cre workflow simulate`. + workflowFolderName := "" + if cwd, err := os.Getwd(); err == nil { + workflowFolderName = filepath.Base(cwd) + } + return Inputs{ - WasmPath: v.GetString("wasm"), - WorkflowPath: creSettings.Workflow.WorkflowArtifactSettings.WorkflowPath, - ConfigPath: cmdcommon.ResolveConfigPath(v, creSettings.Workflow.WorkflowArtifactSettings.ConfigPath), - SecretsPath: creSettings.Workflow.WorkflowArtifactSettings.SecretsPath, - EngineLogs: v.GetBool("engine-logs"), - Broadcast: v.GetBool("broadcast"), - ChainTypeClients: ctClients, - ChainTypeResolved: ctResolved, - ChainTypeKeys: ctKeys, - WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, - NonInteractive: v.GetBool("non-interactive"), - HasTriggerIndex: v.IsSet("trigger-index"), - TriggerIndex: v.GetInt("trigger-index"), - HTTPPayload: v.GetString("http-payload"), - ChainTypeInputs: chain.CollectAllCLIInputs(v), - Listen: v.GetBool("listen"), - LimitsPath: v.GetString("limits"), - SkipTypeChecks: v.GetBool(cmdcommon.SkipTypeChecksCLIFlag), - InvocationDir: h.runtimeContext.InvocationDir, + WasmPath: v.GetString("wasm"), + WorkflowPath: creSettings.Workflow.WorkflowArtifactSettings.WorkflowPath, + ConfigPath: cmdcommon.ResolveConfigPath(v, creSettings.Workflow.WorkflowArtifactSettings.ConfigPath), + SecretsPath: creSettings.Workflow.WorkflowArtifactSettings.SecretsPath, + EngineLogs: v.GetBool("engine-logs"), + Broadcast: v.GetBool("broadcast"), + ChainTypeClients: ctClients, + ChainTypeResolved: ctResolved, + ChainTypeKeys: ctKeys, + WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, + NonInteractive: v.GetBool("non-interactive"), + HasTriggerIndex: v.IsSet("trigger-index"), + TriggerIndex: v.GetInt("trigger-index"), + HTTPPayload: v.GetString("http-payload"), + HTTPTriggerPort: httpTriggerPort, + ChainTypeInputs: chain.CollectAllCLIInputs(v), + Listen: v.GetBool("listen"), + LimitsPath: v.GetString("limits"), + SkipTypeChecks: v.GetBool(cmdcommon.SkipTypeChecksCLIFlag), + InvocationDir: h.runtimeContext.InvocationDir, + WorkflowFolderName: workflowFolderName, }, nil } @@ -268,6 +293,9 @@ func (h *handler) Execute(ctx context.Context, inputs Inputs) error { var err error if inputs.WasmPath != "" { + if h.runtimeContext != nil { + h.runtimeContext.Workflow.Language = constants.WorkflowLanguageWasm + } if cmdcommon.IsURL(inputs.WasmPath) { ui.Dim("Fetching WASM binary from URL...") wasmFileBinary, err = cmdcommon.FetchURL(ctx, inputs.WasmPath) @@ -287,9 +315,6 @@ func (h *handler) Execute(ctx context.Context, inputs Inputs) error { if err != nil { return fmt.Errorf("failed to decode WASM binary: %w", err) } - if h.runtimeContext != nil { - h.runtimeContext.Workflow.Language = constants.WorkflowLanguageWasm - } } else { workflowDir, err := os.Getwd() if err != nil { @@ -332,7 +357,8 @@ func (h *handler) Execute(ctx context.Context, inputs Inputs) error { if simLimits != nil { binaryLimit := simLimits.WASMBinarySize() if binaryLimit > 0 && len(wasmFileBinary) > binaryLimit { - return fmt.Errorf("WASM binary size %d bytes exceeds limit of %d bytes", len(wasmFileBinary), binaryLimit) + return limitExceeded(LimitWASMBinary, "WASM binary", uint64(len(wasmFileBinary)), uint64(binaryLimit), true, + "Reduce compiled binary size (strip symbols, enable size-optimized build)") } compressedLimit := simLimits.WASMCompressedBinarySize() @@ -342,7 +368,8 @@ func (h *handler) Execute(ctx context.Context, inputs Inputs) error { return fmt.Errorf("failed to compress brotli: %w", err) } if len(compressed) > compressedLimit { - return fmt.Errorf("WASM compressed binary size %d bytes exceeds limit of %d bytes", len(compressed), compressedLimit) + return limitExceeded(LimitWASMCompressedBinary, "WASM compressed binary", uint64(len(compressed)), uint64(compressedLimit), true, + "Reduce compiled binary size — even compressed it exceeds the limit") } } @@ -384,7 +411,7 @@ func (h *handler) Execute(ctx context.Context, inputs Inputs) error { } // Set up context for signal handling - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGKILL) + ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGKILL) defer cancel() // if logger instance is set to DEBUG, that means verbosity flag is set by the user @@ -440,12 +467,39 @@ func run( return fmt.Errorf("failed to create engine logger: %w", err) } + // hookCtx is canceled whenever a fatal error occurs inside a lifecycle hook + // that cannot return an error directly (e.g. simulatorInitialize, BeforeStart). + // Canceling it unblocks the waitFn and causes Run() to stop. + hookCtx, hookCancel := context.WithCancel(ctx) + defer hookCancel() + + // lifecycleErr captures the first fatal error from any hook that calls os.Exit. + // executionResultErr captures an error returned by the engine's execution result. + // listen records whether the active trigger is running in --listen mode, so the + // lifecycle hooks below know whether a per-execution error should end the whole + // session or just be reported while the simulator keeps listening for the next request. + var ( + lifecycleErr error + executionResultErr error + listen bool + ) + + // setLifecycleErr records the first lifecycle error and cancels hookCtx so + // all blocking selects unblock and Run() returns promptly. + setLifecycleErr := func(err error) { + if lifecycleErr == nil { + lifecycleErr = err + } + hookCancel() + } + // Channels to coordinate blocking. executionFinishedCh is buffered so multiple // runs (listen mode) can each signal completion without blocking the engine. initializedCh := make(chan struct{}) executionFinishedCh := make(chan struct{}, 1) var manualTriggerCaps *ManualTriggers + var beholderStarted bool simulatorInitialize := func(ctx context.Context, cfg simulator.RunnerConfig) (*capabilities.Registry, []services.Service) { lggr := logger.Sugared(cfg.Lggr) // Create the registry and fake capabilities with specific loggers @@ -459,8 +513,8 @@ func run( bs := simulator.NewBillingService(billingLggr) err := bs.Start(ctx) if err != nil { - ui.Error(fmt.Sprintf("Failed to start billing service: %v", err)) - os.Exit(1) + setLifecycleErr(fmt.Errorf("failed to start billing service: %w", err)) + return registry, srvcs } srvcs = append(srvcs, bs) @@ -468,20 +522,21 @@ func run( if cfg.EnableBeholder { beholderLggr := lggr.Named("Beholder") - err := setupCustomBeholder(beholderLggr, verbosity, simLogger) + err := setupCustomBeholder(ctx, beholderLggr, verbosity, simLogger) if err != nil { - ui.Error(fmt.Sprintf("Failed to setup beholder: %v", err)) - os.Exit(1) + setLifecycleErr(fmt.Errorf("failed to setup beholder: %w", err)) + return registry, srvcs } + beholderStarted = true } // Register chain-agnostic cron and HTTP triggers triggerLggr := lggr.Named("TriggerCapabilities") var err error - manualTriggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry) + manualTriggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry, inputs.HTTPTriggerPort, simLimits) if err != nil { - ui.Error(fmt.Sprintf("Failed to create trigger capabilities: %v", err)) - os.Exit(1) + setLifecycleErr(fmt.Errorf("failed to create trigger capabilities: %w", err)) + return registry, srvcs } srvcs = append(srvcs, manualTriggerCaps.ManualCronTrigger, manualTriggerCaps.ManualHTTPTrigger) @@ -508,8 +563,8 @@ func run( Logger: triggerLggr, }) if err != nil { - ui.Error(fmt.Sprintf("Failed to register %s capabilities: %v", name, err)) - os.Exit(1) + setLifecycleErr(fmt.Errorf("failed to register %s capabilities: %w", name, err)) + return registry, srvcs } srvcs = append(srvcs, ctSrvcs...) } @@ -518,21 +573,21 @@ func run( computeLggr := lggr.Named("ActionsCapabilities") computeCaps, err := NewFakeActionCapabilities(ctx, computeLggr, registry, inputs.SecretsPath, simLimits) if err != nil { - ui.Error(fmt.Sprintf("Failed to create compute capabilities: %v", err)) - os.Exit(1) + setLifecycleErr(fmt.Errorf("failed to create compute capabilities: %w", err)) + return registry, srvcs } // Start trigger capabilities if err := manualTriggerCaps.Start(ctx); err != nil { - ui.Error(fmt.Sprintf("Failed to start trigger: %v", err)) - os.Exit(1) + setLifecycleErr(fmt.Errorf("failed to start trigger capabilities: %w", err)) + return registry, srvcs } // Start compute capabilities for _, cap := range computeCaps { if err = cap.Start(ctx); err != nil { - ui.Error(fmt.Sprintf("Failed to start capability: %v", err)) - os.Exit(1) + setLifecycleErr(fmt.Errorf("failed to start capability %s: %w", cap.Name(), err)) + return registry, srvcs } } @@ -544,32 +599,44 @@ func run( triggerInfoAndBeforeStart := &TriggerInfoAndBeforeStart{} getManualTriggerCaps := func() *ManualTriggers { return manualTriggerCaps } + var limitsWorkflows *cresettings.Workflows + if simLimits != nil { + limitsWorkflows = &simLimits.Workflows + } if inputs.NonInteractive { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggerCaps) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggerCaps, setLifecycleErr, limitsWorkflows) } else { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggerCaps) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggerCaps, setLifecycleErr, limitsWorkflows) } waitFn := func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service) { - <-initializedCh + // Wait for the engine to initialize, or bail out if a lifecycle error occurred. + select { + case <-initializedCh: + case <-hookCtx.Done(): + return + } + + if lifecycleErr != nil { + return + } // Manual trigger execution if triggerInfoAndBeforeStart.TriggerFunc == nil { - simLogger.Error("Trigger function not initialized") - os.Exit(1) + setLifecycleErr(fmt.Errorf("trigger function not initialized")) + return } if triggerInfoAndBeforeStart.TriggerToRun == nil { - simLogger.Error("Trigger to run not selected") - os.Exit(1) + setLifecycleErr(fmt.Errorf("trigger to run not selected")) + return } - httpListen := inputs.Listen && triggerInfoAndBeforeStart.TriggerToRun.GetId() == "http-trigger@1.0.0-alpha" - listen := inputs.Listen && (httpListen || triggerInfoAndBeforeStart.ListenSupported) + listen = inputs.Listen && (httpListen || triggerInfoAndBeforeStart.ListenSupported) if inputs.Listen && !listen { ui.Warning("--listen is not supported for this trigger type; ignoring") } if httpListen { - runHTTPListen(ctx, inputs, triggerInfoAndBeforeStart, executionFinishedCh, simLogger) + runHTTPListen(hookCtx, inputs, triggerInfoAndBeforeStart, executionFinishedCh, simLogger, setLifecycleErr) return } @@ -587,14 +654,21 @@ func run( simLogger.Info("Running trigger", "trigger", triggerInfoAndBeforeStart.TriggerToRun.GetId()) err := triggerInfoAndBeforeStart.TriggerFunc() if err != nil { - simLogger.Error("Failed to run trigger", "trigger", triggerInfoAndBeforeStart.TriggerToRun.GetId(), "error", err) - os.Exit(1) + if errors.Is(err, errHTTPTriggerRateLimited) { + simLogger.Warn("Trigger rate limited, skipping execution", "trigger", triggerInfoAndBeforeStart.TriggerToRun.GetId(), "limit", err) + if !listen { + return + } + continue + } + setLifecycleErr(fmt.Errorf("failed to run trigger %s: %w", triggerInfoAndBeforeStart.TriggerToRun.GetId(), err)) + return } select { case <-executionFinishedCh: simLogger.Info("Execution finished signal received") - case <-ctx.Done(): + case <-hookCtx.Done(): simLogger.Info("Received interrupt signal, stopping execution") return case <-time.After(WorkflowExecutionTimeout): @@ -618,13 +692,16 @@ func run( } } - err := cleanupBeholder() + err := cleanupBeholder(beholderStarted) if err != nil { simLogger.Warn("Failed to cleanup beholder", "error", err) } } emptyHook := func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service) {} + // Acknowledge confidential (TEE) execution once, the first time a TEE handler runs. + var teeAckOnce sync.Once + simulator.NewRunner(&simulator.RunnerHooks{ Initialize: simulatorInitialize, BeforeStart: triggerInfoAndBeforeStart.BeforeStart, @@ -632,28 +709,52 @@ func run( AfterRun: emptyHook, Cleanup: simulatorCleanup, Finally: emptyHook, - }).Run(ctx, inputs.WorkflowName, binary, config, secrets, simulator.RunnerConfig{ + }).Run(hookCtx, inputs.WorkflowName, binary, config, secrets, simulator.RunnerConfig{ EnableBeholder: true, EnableBilling: false, Lggr: engineLog, LifecycleHooks: v2.LifecycleHooks{ OnInitialized: func(err error) { if err != nil { - simLogger.Error("Failed to initialize simulator", "error", err) - os.Exit(1) + setLifecycleErr(fmt.Errorf("failed to initialize simulator: %w", err)) + return } simLogger.Info("Simulator Initialized") ui.Line() close(initializedCh) }, + OnRequirementsSet: func(_ string, requirements *pb.Requirements) { + if requirements.GetTee() == nil { + return + } + teeAckOnce.Do(func() { + ui.Line() + ui.Bold("Confidential (TEE) execution") + ui.Dim("This workflow declares a TEE handler (HandlerInTee). In production it runs inside a " + + teeRequirementSummary(requirements.GetTee()) + " enclave, where secrets are decrypted and " + + "are not visible to node operators. The simulator runs the same workflow logic locally, " + + "without a real enclave or attestation.") + ui.Line() + }) + }, OnExecutionError: func(msg string) { - ui.Error("Workflow execution failed:") - ui.Print(msg) - os.Exit(1) + errMsg := msg + // Engine-enforced limits (e.g. call count limits) produce "limit exceeded" + // errors but don't go through our capability wrappers, so they lack the + // remediation hint. Detect and append it here. + if strings.Contains(strings.ToLower(msg), "limit exceeded") { + errMsg += "\nThis limit mirrors a production constraint.\nUse 'cre workflow limits export' to customize limits, or --limits=none to disable." + } + // Engine-level execution errors are fatal even in --listen mode (they + // indicate the harness itself broke, not just a bad request), so end + // the whole session rather than continuing to listen. + // Do not print here — root.go prints the returned error once after cleanup. + executionResultErr = fmt.Errorf("workflow execution failed: %s", errMsg) + hookCancel() }, OnResultReceived: func(result *pb.ExecutionResult) { if result == nil || result.Result == nil { - // OnExecutionError will print the error message of the crash. + // OnExecutionError will handle the crash message. return } @@ -681,7 +782,14 @@ func run( ui.Success("Workflow Simulation Result:") ui.Print(string(j)) case *pb.ExecutionResult_Error: - ui.Error("Execution resulted in an error being returned: " + r.Error) + if listen { + // In listen mode a single bad request shouldn't end the session; + // report it and keep listening for the next one. + ui.Error("Execution resulted in an error being returned: " + r.Error) + } else { + // Do not print here — root.go prints the returned error once after cleanup. + executionResultErr = fmt.Errorf("workflow execution returned an error: %s", r.Error) + } } ui.Line() select { @@ -705,27 +813,69 @@ func run( }, }) + if lifecycleErr != nil { + return lifecycleErr + } + if executionResultErr != nil { + return executionResultErr + } return nil } -func runHTTPListen(ctx context.Context, inputs Inputs, triggerInfo *TriggerInfoAndBeforeStart, executionFinishedCh <-chan struct{}, simLogger *SimulationLogger) { +// teeRequirementSummary renders a short human-readable description of a TEE +// requirement (type and regions) for the confidential-execution acknowledgement. +func teeRequirementSummary(tee *pb.Tee) string { + if tee == nil { + return "TEE" + } + if ttr := tee.GetTeeTypesAndRegions(); ttr != nil { + var parts []string + for _, tr := range ttr.GetTeeTypeAndRegions() { + name := strings.TrimPrefix(tr.GetType().String(), "TEE_TYPE_") + if regions := tr.GetRegions(); len(regions) > 0 { + parts = append(parts, fmt.Sprintf("%s (%s)", name, strings.Join(regions, ", "))) + } else { + parts = append(parts, name) + } + } + if len(parts) > 0 { + return strings.Join(parts, ", ") + } + } + if r := tee.GetAnyRegions(); r != nil { + if regions := r.GetRegions(); len(regions) > 0 { + return "TEE (" + strings.Join(regions, ", ") + ")" + } + } + return "TEE" +} + +// runHTTPListen serves the HTTP trigger's --listen mode: it keeps a single HTTP +// server running for the whole session and runs the trigger once per incoming +// request, instead of exiting after the first one. onErr is called instead of +// os.Exit when a fatal (session-ending) error occurs. +func runHTTPListen(ctx context.Context, inputs Inputs, triggerInfo *TriggerInfoAndBeforeStart, executionFinishedCh <-chan struct{}, simLogger *SimulationLogger, onErr func(error)) { if triggerInfo.TriggerWithPayload == nil { - simLogger.Error("HTTP trigger payload function not initialized") - os.Exit(1) + onErr(fmt.Errorf("HTTP trigger payload function not initialized")) + return } - payloadCh, closeServer, err := startHTTPListenPayloadServer(ctx, httpTriggerServerPort) + payloadCh, closeServer, err := startHTTPListenPayloadServer(ctx, inputs.HTTPTriggerPort) if err != nil { - ui.Error(fmt.Sprintf("Failed to start HTTP trigger server: %v", err)) - os.Exit(1) + onErr(fmt.Errorf("failed to start HTTP trigger server: %w", err)) + return } defer closeServer() runPayload := func(payload *httptypedapi.Payload) bool { simLogger.Info("Running trigger", "trigger", triggerInfo.TriggerToRun.GetId()) if err := triggerInfo.TriggerWithPayload(payload); err != nil { - simLogger.Error("Failed to run trigger", "trigger", triggerInfo.TriggerToRun.GetId(), "error", err) - os.Exit(1) + if errors.Is(err, errHTTPTriggerRateLimited) { + simLogger.Warn("Trigger rate limited, skipping execution", "trigger", triggerInfo.TriggerToRun.GetId(), "limit", err) + return true + } + onErr(fmt.Errorf("failed to run trigger %s: %w", triggerInfo.TriggerToRun.GetId(), err)) + return false } select { @@ -744,24 +894,29 @@ func runHTTPListen(ctx context.Context, inputs Inputs, triggerInfo *TriggerInfoA iteration := 0 if strings.TrimSpace(inputs.HTTPPayload) != "" { if triggerInfo.TriggerFunc == nil { - simLogger.Error("Trigger function not initialized") - os.Exit(1) + onErr(fmt.Errorf("trigger function not initialized")) + return } simLogger.Info("Running trigger", "trigger", triggerInfo.TriggerToRun.GetId()) if err := triggerInfo.TriggerFunc(); err != nil { - simLogger.Error("Failed to run trigger", "trigger", triggerInfo.TriggerToRun.GetId(), "error", err) - os.Exit(1) - } - select { - case <-executionFinishedCh: - simLogger.Info("Execution finished signal received") - case <-ctx.Done(): - simLogger.Info("Received interrupt signal, stopping execution") - return - case <-time.After(WorkflowExecutionTimeout): - simLogger.Warn("Timeout waiting for execution to finish") + if errors.Is(err, errHTTPTriggerRateLimited) { + simLogger.Warn("Trigger rate limited, skipping execution", "trigger", triggerInfo.TriggerToRun.GetId(), "limit", err) + } else { + onErr(fmt.Errorf("failed to run trigger %s: %w", triggerInfo.TriggerToRun.GetId(), err)) + return + } + } else { + select { + case <-executionFinishedCh: + simLogger.Info("Execution finished signal received") + case <-ctx.Done(): + simLogger.Info("Received interrupt signal, stopping execution") + return + case <-time.After(WorkflowExecutionTimeout): + simLogger.Warn("Timeout waiting for execution to finish") + } + iteration = 1 } - iteration = 1 } for { @@ -769,7 +924,7 @@ func runHTTPListen(ctx context.Context, inputs Inputs, triggerInfo *TriggerInfoA ui.Line() ui.Step(fmt.Sprintf("Listen: ready for next request (run #%d)", iteration+1)) } - ui.Step(fmt.Sprintf("Waiting for HTTP request to start execution (listening on http://localhost:%d/trigger)...", httpTriggerServerPort)) + ui.Step(fmt.Sprintf("Waiting for HTTP request to start execution (listening on http://localhost:%d/trigger)...", inputs.HTTPTriggerPort)) var payload *httptypedapi.Payload select { @@ -862,40 +1017,10 @@ type TriggerInfoAndBeforeStart struct { BeforeStart func(ctx context.Context, cfg simulator.RunnerConfig, registry *capabilities.Registry, services []services.Service, triggerSub []*pb.TriggerSubscription) } -func getTriggerIndex(inputs Inputs, triggerSub []*pb.TriggerSubscription) (int, error) { - if len(triggerSub) == 0 { - return -1, errors.New("no workflow triggers found, please check your workflow source code and config") - } - - if len(triggerSub) == 1 { - return 0, nil - } - - if inputs.HasTriggerIndex { - return inputs.TriggerIndex, nil - } - - opts := make([]ui.SelectOption[int], len(triggerSub)) - for i, trigger := range triggerSub { - opts[i] = ui.SelectOption[int]{ - Label: fmt.Sprintf("%s %s", trigger.GetId(), trigger.GetMethod()), - Value: i, - } - } - - ui.Line() - selected, err := ui.Select("Workflow simulation ready. Please select a trigger:", opts) - if err != nil { - ui.Error(fmt.Sprintf("Trigger selection failed: %v", err)) - os.Exit(1) - } - ui.Line() - - return selected, nil -} - -// makeBeforeStartInteractive builds the interactive BeforeStart closure -func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { +// makeBeforeStartInteractive builds the interactive BeforeStart closure. +// onErr is called instead of os.Exit when a fatal error occurs; it records +// the error and cancels the hook context so Run() unblocks and returns. +func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggerCapsGetter func() *ManualTriggers, onErr func(error), limits *cresettings.Workflows) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -903,14 +1028,33 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs services []services.Service, triggerSub []*pb.TriggerSubscription, ) { - triggerIndex, err := getTriggerIndex(inputs, triggerSub) - if err != nil { - ui.Error(fmt.Sprintf("Workflow initialization failed: %v", err)) - os.Exit(1) + if len(triggerSub) == 0 { + onErr(fmt.Errorf("no workflow triggers found; check your workflow source code and config")) + return } - if triggerIndex < 0 || triggerIndex >= len(triggerSub) { - ui.Error(fmt.Sprintf("Workflow initialization failed: trigger index out of range: %d", triggerIndex)) - os.Exit(1) + + var triggerIndex int + if len(triggerSub) > 1 { + opts := make([]ui.SelectOption[int], len(triggerSub)) + for i, trigger := range triggerSub { + opts[i] = ui.SelectOption[int]{ + Label: fmt.Sprintf("%s %s", trigger.GetId(), trigger.GetMethod()), + Value: i, + } + } + + ui.Line() + selected, err := ui.Select("Workflow simulation ready. Please select a trigger:", opts) + if err != nil { + onErr(fmt.Errorf("trigger selection failed: %w", err)) + return + } + triggerIndex = selected + + holder.TriggerToRun = triggerSub[triggerIndex] + ui.Line() + } else { + holder.TriggerToRun = triggerSub[0] } holder.TriggerToRun = triggerSub[triggerIndex] @@ -939,14 +1083,14 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs case "http-trigger@1.0.0-alpha": payload, err := getHTTPTriggerPayloadFromInput(inputs.InvocationDir, inputs.HTTPPayload) if err != nil { - ui.Error(fmt.Sprintf("Failed to get HTTP trigger payload: %v", err)) - os.Exit(1) + onErr(fmt.Errorf("failed to get HTTP trigger payload: %w", err)) + return } if payload == nil { ui.Line() ui.Step("No input detected for http-trigger. Supply the payload using one of:") ui.Dim("1. POST JSON to the local trigger server, example:") - ui.Dim(fmt.Sprintf(` curl -X POST http://localhost:%d/trigger \`, httpTriggerServerPort)) + ui.Dim(fmt.Sprintf(` curl -X POST http://localhost:%d/trigger \`, inputs.HTTPTriggerPort)) ui.Dim(" -H 'Content-Type: application/json' \\") ui.Dim(" -d '{\"input\":{\"key\":\"value\"}}'") ui.Dim("2. Re-run with --http-payload flag:") @@ -960,7 +1104,7 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs p := payload payload = nil if p == nil { - ui.Step(fmt.Sprintf("Waiting for HTTP request to start execution (listening on http://localhost:%d/trigger)...", httpTriggerServerPort)) + ui.Step(fmt.Sprintf("Waiting for HTTP request to start execution (listening on http://localhost:%d/trigger)...", inputs.HTTPTriggerPort)) } return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, p) } @@ -977,8 +1121,8 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs } if !ct.Supports(sel) { - ui.Error(fmt.Sprintf("%s unsupported or misconfigured chain for selector %d", name, sel)) - os.Exit(1) + onErr(fmt.Errorf("%s unsupported or misconfigured chain for selector %d", name, sel)) + return } if inputs.Listen { @@ -990,9 +1134,10 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs Clients: inputs.ChainTypeClients[ct.Name()], Interactive: true, Listen: true, + Limits: limits, ChainTypeInputs: inputs.ChainTypeInputs, TriggerPayload: holder.TriggerToRun.GetPayload(), - WorkflowName: inputs.WorkflowName, + WorkflowName: inputs.WorkflowFolderName, }) if err != nil { ui.Error(fmt.Sprintf("Failed to create %s trigger listener: %v", name, err)) @@ -1010,10 +1155,10 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs break } - triggerData, err := getTriggerDataForChainType(ctx, ct, sel, holder.TriggerToRun, inputs, true) + triggerData, err := getTriggerDataForChainType(ctx, ct, sel, holder.TriggerToRun, inputs, limits, true) if err != nil { - ui.Error(fmt.Sprintf("Failed to get %s trigger data: %v", name, err)) - os.Exit(1) + onErr(fmt.Errorf("failed to get %s trigger data: %w", name, err)) + return } handled = true @@ -1024,15 +1169,17 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs } if !handled { - ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) - os.Exit(1) + onErr(fmt.Errorf("unsupported trigger type: %s", holder.TriggerToRun.Id)) + return } } } } -// makeBeforeStartNonInteractive builds the non-interactive BeforeStart closure -func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { +// makeBeforeStartNonInteractive builds the non-interactive BeforeStart closure. +// onErr is called instead of os.Exit when a fatal error occurs; it records +// the error and cancels the hook context so Run() unblocks and returns. +func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggerCapsGetter func() *ManualTriggers, onErr func(error), limits *cresettings.Workflows) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -1041,16 +1188,16 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp triggerSub []*pb.TriggerSubscription, ) { if len(triggerSub) == 0 { - ui.Error("No workflow triggers found, please check your workflow source code and config") - os.Exit(1) + onErr(fmt.Errorf("no workflow triggers found; check your workflow source code and config")) + return } if inputs.TriggerIndex < 0 { - ui.Error("--trigger-index is required when --non-interactive is enabled") - os.Exit(1) + onErr(fmt.Errorf("--trigger-index is required when --non-interactive is enabled")) + return } if inputs.TriggerIndex >= len(triggerSub) { - ui.Error(fmt.Sprintf("Invalid --trigger-index %d; available range: 0-%d", inputs.TriggerIndex, len(triggerSub)-1)) - os.Exit(1) + onErr(fmt.Errorf("invalid --trigger-index %d; available range: 0-%d", inputs.TriggerIndex, len(triggerSub)-1)) + return } holder.TriggerToRun = triggerSub[inputs.TriggerIndex] @@ -1067,20 +1214,20 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp return manualTriggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, skipWaitSignal) } case "http-trigger@1.0.0-alpha": - if strings.TrimSpace(inputs.HTTPPayload) == "" && !inputs.Listen { - ui.Error("--http-payload is required for http-trigger@1.0.0-alpha in non-interactive mode") - os.Exit(1) + if strings.TrimSpace(inputs.HTTPPayload) == "" { + onErr(fmt.Errorf("--http-payload is required for http-trigger@1.0.0-alpha in non-interactive mode")) + return } payload, err := getHTTPTriggerPayloadFromInput(inputs.InvocationDir, inputs.HTTPPayload) if err != nil { - ui.Error(fmt.Sprintf("Failed to parse HTTP trigger payload: %v", err)) - os.Exit(1) + onErr(fmt.Errorf("failed to parse HTTP trigger payload: %w", err)) + return } holder.TriggerFunc = func() error { p := payload payload = nil if p == nil { - ui.Step(fmt.Sprintf("Waiting for HTTP request to start execution (listening on http://localhost:%d/trigger)...", httpTriggerServerPort)) + ui.Step(fmt.Sprintf("Waiting for HTTP request to start execution (listening on http://localhost:%d/trigger)...", inputs.HTTPTriggerPort)) } return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, p) } @@ -1097,8 +1244,8 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp } if !ct.Supports(sel) { - ui.Error(fmt.Sprintf("%s unsupported or misconfigured chain for selector %d", name, sel)) - os.Exit(1) + onErr(fmt.Errorf("%s unsupported or misconfigured chain for selector %d", name, sel)) + return } if inputs.Listen { @@ -1110,9 +1257,10 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp Clients: inputs.ChainTypeClients[ct.Name()], Interactive: false, Listen: true, + Limits: limits, ChainTypeInputs: inputs.ChainTypeInputs, TriggerPayload: holder.TriggerToRun.GetPayload(), - WorkflowName: inputs.WorkflowName, + WorkflowName: inputs.WorkflowFolderName, }) if err != nil { ui.Error(fmt.Sprintf("Failed to create %s trigger listener: %v", name, err)) @@ -1130,10 +1278,10 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp break } - triggerData, err := getTriggerDataForChainType(ctx, ct, sel, holder.TriggerToRun, inputs, false) + triggerData, err := getTriggerDataForChainType(ctx, ct, sel, holder.TriggerToRun, inputs, limits, false) if err != nil { - ui.Error(fmt.Sprintf("Failed to get %s trigger data: %v", name, err)) - os.Exit(1) + onErr(fmt.Errorf("failed to get %s trigger data: %w", name, err)) + return } handled = true @@ -1144,8 +1292,8 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp } if !handled { - ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) - os.Exit(1) + onErr(fmt.Errorf("unsupported trigger type: %s", holder.TriggerToRun.Id)) + return } } } @@ -1160,7 +1308,7 @@ func getLevel(verbosity bool, defaultLevel zapcore.Level) zapcore.Level { } // setupCustomBeholder sets up beholder with our custom telemetry writer -func setupCustomBeholder(lggr logger.Logger, verbosity bool, simLogger *SimulationLogger) error { +func setupCustomBeholder(ctx context.Context, lggr logger.Logger, verbosity bool, simLogger *SimulationLogger) error { writer := &telemetryWriter{lggr: lggr, verbose: verbosity, simLogger: simLogger} client, err := beholder.NewWriterClient(writer) @@ -1168,12 +1316,20 @@ func setupCustomBeholder(lggr logger.Logger, verbosity bool, simLogger *Simulati return err } + if err := client.Start(ctx); err != nil { + return err + } + beholder.SetClient(client) return nil } -func cleanupBeholder() error { +func cleanupBeholder(started bool) error { + if !started { + return nil + } + client := beholder.GetClient() if client != nil { return client.Close() @@ -1232,14 +1388,15 @@ func getHTTPTriggerPayloadFromInput(invocationDir, input string) (*httptypedapi. // getTriggerDataForChainType resolves trigger data for a specific chain type. // Each chain type defines its own trigger data format. -func getTriggerDataForChainType(ctx context.Context, ct chain.ChainType, selector uint64, triggerSub *pb.TriggerSubscription, inputs Inputs, interactive bool) (interface{}, error) { +func getTriggerDataForChainType(ctx context.Context, ct chain.ChainType, selector uint64, triggerSub *pb.TriggerSubscription, inputs Inputs, limits *cresettings.Workflows, interactive bool) (interface{}, error) { return ct.ResolveTriggerData(ctx, selector, chain.TriggerParams{ Clients: inputs.ChainTypeClients[ct.Name()], Interactive: interactive, Listen: inputs.Listen, + Limits: limits, ChainTypeInputs: inputs.ChainTypeInputs, TriggerPayload: triggerSub.GetPayload(), - WorkflowName: inputs.WorkflowName, + WorkflowName: inputs.WorkflowFolderName, }) } diff --git a/cmd/workflow/simulate/simulate_logger.go b/cmd/workflow/simulate/simulate_logger.go index 6fae563b..cac1f4ba 100644 --- a/cmd/workflow/simulate/simulate_logger.go +++ b/cmd/workflow/simulate/simulate_logger.go @@ -249,20 +249,18 @@ func CleanLogMessage(msg string) string { // Common patterns: time=..., timestamp=..., ts=..., level=... msg = strings.TrimSpace(msg) - // Remove time=... patterns - timePattern := regexp.MustCompile(`time=\S+\s*`) + // Anchor on word-boundary so we don't eat substrings inside user words + // (e.g. `lamports=` was getting its `ts=` portion stripped). + timePattern := regexp.MustCompile(`\btime=\S+\s*`) msg = timePattern.ReplaceAllString(msg, "") - // Remove timestamp=... patterns - timestampPattern := regexp.MustCompile(`timestamp=\S+\s*`) + timestampPattern := regexp.MustCompile(`\btimestamp=\S+\s*`) msg = timestampPattern.ReplaceAllString(msg, "") - // Remove ts=... patterns - tsPattern := regexp.MustCompile(`ts=\S+\s*`) + tsPattern := regexp.MustCompile(`\bts=\S+\s*`) msg = tsPattern.ReplaceAllString(msg, "") - // Remove level=... patterns - levelPattern := regexp.MustCompile(`level=\S+\s*`) + levelPattern := regexp.MustCompile(`\blevel=\S+\s*`) msg = levelPattern.ReplaceAllString(msg, "") return strings.TrimSpace(msg) diff --git a/cmd/workflow/simulate/simulate_test.go b/cmd/workflow/simulate/simulate_test.go index e1db93f9..7dac1193 100644 --- a/cmd/workflow/simulate/simulate_test.go +++ b/cmd/workflow/simulate/simulate_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net" @@ -98,8 +99,10 @@ func TestBlankWorkflowSimulation(t *testing.T) { Settings: &settings.Settings{ Workflow: workflowSettings, User: settings.UserSettings{ - TargetName: "staging-settings", - PrivateKeys: map[string]string{settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888"}, + TargetName: "staging-settings", + PrivateKeys: map[string]string{ + settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888", + }, }, }, } @@ -151,7 +154,9 @@ func createSimulateTestSettings(workflowName, workflowPath, configPath string) * }, }, User: settings.UserSettings{ - PrivateKeys: map[string]string{settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888"}, + PrivateKeys: map[string]string{ + settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888", + }, }, } } @@ -256,6 +261,45 @@ func TestSimulateResolveInputs_WasmFlag(t *testing.T) { }) } +func TestSimulateResolveInputs_HTTPTriggerPort(t *testing.T) { + t.Parallel() + + t.Run("default port when flag unset", func(t *testing.T) { + t.Parallel() + v := createSimulateTestViper(t) + creSettings := createSimulateTestSettings("test-workflow", "main.go", "") + + runtimeCtx := &runtime.Context{ + Logger: testutil.NewTestLogger(), + Viper: v, + Settings: creSettings, + } + h := newHandler(runtimeCtx) + + inputs, err := h.ResolveInputs(v, creSettings) + require.NoError(t, err) + assert.Equal(t, defaultHTTPTriggerServerPort, inputs.HTTPTriggerPort) + }) + + t.Run("flag overrides default", func(t *testing.T) { + t.Parallel() + v := createSimulateTestViper(t) + v.Set("http-trigger-port", 3210) + creSettings := createSimulateTestSettings("test-workflow", "main.go", "") + + runtimeCtx := &runtime.Context{ + Logger: testutil.NewTestLogger(), + Viper: v, + Settings: creSettings, + } + h := newHandler(runtimeCtx) + + inputs, err := h.ResolveInputs(v, creSettings) + require.NoError(t, err) + assert.Equal(t, 3210, inputs.HTTPTriggerPort) + }) +} + func TestSimulateValidateInputs_URLBypass(t *testing.T) { t.Parallel() @@ -268,10 +312,11 @@ func TestSimulateValidateInputs_URLBypass(t *testing.T) { h := newHandler(runtimeCtx) inputs := Inputs{ - WorkflowPath: tmpFile, - ConfigPath: "https://example.com/config.yaml", - WasmPath: "https://example.com/binary.wasm", - WorkflowName: "test-workflow", + WorkflowPath: tmpFile, + ConfigPath: "https://example.com/config.yaml", + WasmPath: "https://example.com/binary.wasm", + HTTPTriggerPort: defaultHTTPTriggerServerPort, + WorkflowName: "test-workflow", } err := h.ValidateInputs(inputs) @@ -487,12 +532,12 @@ func TestNonInteractiveCronTriggerDoesNotBlockOnSchedule(t *testing.T) { require.Nil(t, capErr) holder := &TriggerInfoAndBeforeStart{} - inputs := Inputs{TriggerIndex: triggerIndex} + inputs := Inputs{TriggerIndex: triggerIndex, HTTPTriggerPort: defaultHTTPTriggerServerPort} manualTriggers := &ManualTriggers{ManualCronTrigger: cronSvc} beforeStart := makeBeforeStartNonInteractive(holder, inputs, func() *ManualTriggers { return manualTriggers - }) + }, func(err error) { require.NoError(t, err, "unexpected BeforeStart error") }, nil) triggerSub := []*pb.TriggerSubscription{{Id: "cron-trigger@1.0.0"}} beforeStart(ctx, simulator.RunnerConfig{}, nil, nil, triggerSub) @@ -509,6 +554,94 @@ func TestNonInteractiveCronTriggerDoesNotBlockOnSchedule(t *testing.T) { } } +// newTestRuntimeCtx returns a minimal runtime.Context sufficient for Execute() +// to run through the pre-flight checks without panicking. +func newTestRuntimeCtx(t *testing.T) *runtime.Context { + t.Helper() + return &runtime.Context{ + Logger: testutil.NewTestLogger(), + Viper: viper.New(), + Settings: &settings.Settings{}, + } +} + +// TestExecuteWASMBinarySizeLimitReturnsTypedError verifies that the WASM +// binary pre-flight check returns a *LimitExceededError (not os.Exit) so that +// the CLI root can capture it in telemetry.EmitCommandEvent. +func TestExecuteWASMBinarySizeLimitReturnsTypedError(t *testing.T) { + t.Parallel() + + limits, err := DefaultLimits() + require.NoError(t, err) + binaryLimit := limits.WASMBinarySize() + require.Positive(t, binaryLimit) + + // Write a fake WASM binary with a valid magic header, 1 byte over the limit. + // EnsureRawWasm passes data through unchanged when it starts with the WASM magic. + wasmMagic := []byte{0x00, 0x61, 0x73, 0x6d} + oversized := make([]byte, binaryLimit+1) + copy(oversized, wasmMagic) + dir := t.TempDir() + wasmPath := filepath.Join(dir, "workflow.wasm") + require.NoError(t, os.WriteFile(wasmPath, oversized, 0o600)) + + h := newHandler(newTestRuntimeCtx(t)) + execErr := h.Execute(context.Background(), Inputs{ + WasmPath: wasmPath, + WorkflowPath: ".", + WorkflowName: "test-wf", + LimitsPath: "default", + }) + require.Error(t, execErr) + + var limitErr *LimitExceededError + require.True(t, errors.As(execErr, &limitErr), "expected *LimitExceededError, got %T: %v", execErr, execErr) + assert.Equal(t, LimitWASMBinary, limitErr.Kind) + assert.Contains(t, limitErr.Error(), "production") + assert.Contains(t, limitErr.Error(), "--limits=none") + assert.Contains(t, limitErr.Error(), "cre workflow limits export") +} + +// TestExecuteWASMCompressedBinarySizeLimitReturnsTypedError verifies that the +// brotli-compressed size pre-flight check returns a *LimitExceededError. +// It uses a custom limits file with a 1-byte compressed limit so any binary +// triggers the check without needing to generate incompressible data. +func TestExecuteWASMCompressedBinarySizeLimitReturnsTypedError(t *testing.T) { + t.Parallel() + + // Write a custom limits file that sets the compressed size limit to 1 byte. + // Any WASM binary will exceed this after brotli compression. + limitsJSON := `{"WASMCompressedBinarySizeLimit": "1b"}` + dir := t.TempDir() + limitsPath := filepath.Join(dir, "limits.json") + require.NoError(t, os.WriteFile(limitsPath, []byte(limitsJSON), 0o600)) + + // Validate that the limits file is parseable. + _, err := LoadLimits(limitsPath) + require.NoError(t, err, "custom limits file must be valid") + + // A minimal valid WASM binary (just the magic header + version). + wasmMagic := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00} + wasmPath := filepath.Join(dir, "workflow.wasm") + require.NoError(t, os.WriteFile(wasmPath, wasmMagic, 0o600)) + + h := newHandler(newTestRuntimeCtx(t)) + execErr := h.Execute(context.Background(), Inputs{ + WasmPath: wasmPath, + WorkflowPath: ".", + WorkflowName: "test-wf", + LimitsPath: limitsPath, + }) + require.Error(t, execErr) + + var limitErr *LimitExceededError + require.True(t, errors.As(execErr, &limitErr), "expected *LimitExceededError, got %T: %v", execErr, execErr) + assert.Equal(t, LimitWASMCompressedBinary, limitErr.Kind) + assert.Contains(t, limitErr.Error(), "production") + assert.Contains(t, limitErr.Error(), "--limits=none") + assert.Contains(t, limitErr.Error(), "cre workflow limits export") +} + func TestHTTPListenPayloadServerAcceptsMultipleRequests(t *testing.T) { t.Parallel() @@ -526,7 +659,7 @@ func TestHTTPListenPayloadServerAcceptsMultipleRequests(t *testing.T) { require.NoError(t, err) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) // #nosec G704 -- URL targets localhost test server require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.NoError(t, resp.Body.Close()) @@ -545,7 +678,7 @@ func TestHTTPListenPayloadServerAcceptsMultipleRequests(t *testing.T) { func TestManualHTTPTriggerEventsHaveUniqueIDs(t *testing.T) { t.Parallel() - svc := NewManualHTTPTriggerService(logger.Test(t)) + svc := NewManualHTTPTriggerService(logger.Test(t), defaultHTTPTriggerServerPort, nil) first := svc.createManualTriggerEvent(nil) second := svc.createManualTriggerEvent(nil) @@ -561,7 +694,9 @@ func TestSimulateConfigFlagsMutuallyExclusive(t *testing.T) { Viper: viper.New(), Settings: &settings.Settings{ User: settings.UserSettings{ - PrivateKeys: map[string]string{settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888"}, + PrivateKeys: map[string]string{ + settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888", + }, }, }, } diff --git a/docs/cre.md b/docs/cre.md index 81092ba9..3e1decaa 100644 --- a/docs/cre.md +++ b/docs/cre.md @@ -13,6 +13,7 @@ cre [optional flags] ### Options ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info -h, --help help for cre @@ -26,6 +27,7 @@ cre [optional flags] ### SEE ALSO * [cre account](cre_account.md) - Manage account and request deploy access +* [cre execution](cre_execution.md) - Query workflow execution history * [cre generate-bindings](cre_generate-bindings.md) - Generate bindings for contracts * [cre init](cre_init.md) - Initialize a new cre project (recommended starting point) * [cre login](cre_login.md) - Start authentication flow diff --git a/docs/cre_account.md b/docs/cre_account.md index 908033fc..c390daf4 100644 --- a/docs/cre_account.md +++ b/docs/cre_account.md @@ -19,6 +19,7 @@ cre account [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_account_access.md b/docs/cre_account_access.md index a477d1af..d8855ac3 100644 --- a/docs/cre_account_access.md +++ b/docs/cre_account_access.md @@ -19,6 +19,7 @@ cre account access [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_account_link-key.md b/docs/cre_account_link-key.md index 4f04ca28..03f4d6be 100644 --- a/docs/cre_account_link-key.md +++ b/docs/cre_account_link-key.md @@ -22,6 +22,7 @@ cre account link-key [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_account_list-key.md b/docs/cre_account_list-key.md index 771b08f9..4e55d1b7 100644 --- a/docs/cre_account_list-key.md +++ b/docs/cre_account_list-key.md @@ -19,6 +19,7 @@ cre account list-key [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_account_unlink-key.md b/docs/cre_account_unlink-key.md index 37925798..37a9c309 100644 --- a/docs/cre_account_unlink-key.md +++ b/docs/cre_account_unlink-key.md @@ -21,6 +21,7 @@ cre account unlink-key [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_execution.md b/docs/cre_execution.md new file mode 100644 index 00000000..43fe4684 --- /dev/null +++ b/docs/cre_execution.md @@ -0,0 +1,39 @@ +## cre execution + +Query workflow execution history + +### Synopsis + +The execution command provides visibility into workflow executions, node events, and logs. + +``` +cre execution [optional flags] +``` + +### Options + +``` + -h, --help help for execution +``` + +### Options inherited from parent commands + +``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre](cre.md) - CRE CLI tool +* [cre execution events](cre_execution_events.md) - Show the node/capability event timeline for an execution +* [cre execution list](cre_execution_list.md) - List recent executions for a workflow +* [cre execution logs](cre_execution_logs.md) - Show logs emitted during a workflow execution +* [cre execution status](cre_execution_status.md) - Show detailed status of a single execution + diff --git a/docs/cre_execution_events.md b/docs/cre_execution_events.md new file mode 100644 index 00000000..efa54910 --- /dev/null +++ b/docs/cre_execution_events.md @@ -0,0 +1,49 @@ +## cre execution events + +Show the node/capability event timeline for an execution + +### Synopsis + +Fetch and display the ordered sequence of capability events for a workflow +execution, including per-event status, method, duration, and any errors. + +``` +cre execution events [optional flags] +``` + +### Examples + +``` +cre execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g + cre execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --capability fetch-price + cre execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --status FAILURE + cre execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json +``` + +### Options + +``` + --capability string Filter events to a specific capability ID + -h, --help help for events + --json Output as JSON (shorthand for --output=json) + --output string Output format: "json" prints a JSON array to stdout + --status string Filter events by status (e.g. FAILURE) +``` + +### Options inherited from parent commands + +``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre execution](cre_execution.md) - Query workflow execution history + diff --git a/docs/cre_execution_list.md b/docs/cre_execution_list.md new file mode 100644 index 00000000..378df19a --- /dev/null +++ b/docs/cre_execution_list.md @@ -0,0 +1,56 @@ +## cre execution list + +List recent executions for a workflow + +### Synopsis + +List workflow executions from the CRE platform. + +The optional argument accepts either an on-chain Workflow ID (64-char hex, +visible in 'cre workflow list') or a workflow name. When omitted, executions +across all workflows are returned. + +``` +cre execution list [workflow-id-or-name] [flags] +``` + +### Examples + +``` +cre execution list + cre execution list my-workflow + cre execution list 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856 + cre execution list my-workflow --status FAILURE + cre execution list my-workflow --start 2026-01-01T00:00:00Z --end 2026-01-02T00:00:00Z + cre execution list my-workflow --limit 50 --output json +``` + +### Options + +``` + --end string End of time range in ISO8601 format (e.g. 2026-01-02T00:00:00Z) + -h, --help help for list + --json Output as JSON (shorthand for --output=json) + --limit int Maximum number of executions to return (max 100) (default 20) + --output string Output format: "json" prints a JSON array to stdout + --start string Start of time range in ISO8601 format (e.g. 2026-01-01T00:00:00Z) + --status string Filter by execution status (TRIGGERED, IN_PROGRESS, SUCCESS, FAILURE) +``` + +### Options inherited from parent commands + +``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre execution](cre_execution.md) - Query workflow execution history + diff --git a/docs/cre_execution_logs.md b/docs/cre_execution_logs.md new file mode 100644 index 00000000..e0580cad --- /dev/null +++ b/docs/cre_execution_logs.md @@ -0,0 +1,47 @@ +## cre execution logs + +Show logs emitted during a workflow execution + +### Synopsis + +Fetch and display all log lines emitted during a workflow execution. +Use --node to filter to a specific capability node (client-side filter). + +``` +cre execution logs [optional flags] +``` + +### Examples + +``` +cre execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g + cre execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --node ProcessData + cre execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json +``` + +### Options + +``` + -h, --help help for logs + --json Output as JSON (shorthand for --output=json) + --node string Filter logs to a specific node/capability ID (case-insensitive) + --output string Output format: "json" prints a JSON array to stdout +``` + +### Options inherited from parent commands + +``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre execution](cre_execution.md) - Query workflow execution history + diff --git a/docs/cre_execution_status.md b/docs/cre_execution_status.md new file mode 100644 index 00000000..366f07d0 --- /dev/null +++ b/docs/cre_execution_status.md @@ -0,0 +1,45 @@ +## cre execution status + +Show detailed status of a single execution + +### Synopsis + +Fetch and display the full status of a workflow execution, including +top-level errors when the execution has failed. + +``` +cre execution status [optional flags] +``` + +### Examples + +``` +cre execution status 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g + cre execution status 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json +``` + +### Options + +``` + -h, --help help for status + --json Output as JSON (shorthand for --output=json) + --output string Output format: "json" prints JSON to stdout +``` + +### Options inherited from parent commands + +``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre execution](cre_execution.md) - Query workflow execution history + diff --git a/docs/cre_generate-bindings.md b/docs/cre_generate-bindings.md index d1f5befc..7a8a3a54 100644 --- a/docs/cre_generate-bindings.md +++ b/docs/cre_generate-bindings.md @@ -15,6 +15,7 @@ The generate-bindings command allows you to generate bindings for contracts. ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_generate-bindings_evm.md b/docs/cre_generate-bindings_evm.md index bbe4558f..6685ff00 100644 --- a/docs/cre_generate-bindings_evm.md +++ b/docs/cre_generate-bindings_evm.md @@ -37,6 +37,7 @@ cre generate-bindings evm [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_generate-bindings_solana.md b/docs/cre_generate-bindings_solana.md index b95b9d83..02468e55 100644 --- a/docs/cre_generate-bindings_solana.md +++ b/docs/cre_generate-bindings_solana.md @@ -5,9 +5,13 @@ Generate bindings from contract IDL ### Synopsis This command generates bindings from contract IDL files. -Supports Solana chain family and Go language. -Each contract gets its own package subdirectory to avoid naming conflicts. -For example, data_storage.json generates bindings in generated/data_storage/ package. +Supports Solana chain family with Go and TypeScript languages. +The target language is auto-detected from project files, or can be +specified explicitly with --language. +For Go, each contract gets its own package subdirectory to avoid naming +conflicts: data_storage.json generates bindings in generated/data_storage/. +For TypeScript, each contract generates a flat .ts + _mock.ts +pair plus an index.ts barrel. ``` cre generate-bindings solana [optional flags] @@ -16,7 +20,7 @@ cre generate-bindings solana [optional flags] ### Examples ``` - cre generate-bindings-solana + cre generate-bindings solana ``` ### Options @@ -24,14 +28,15 @@ cre generate-bindings solana [optional flags] ``` -h, --help help for solana -i, --idl string Path to IDL directory (defaults to contracts/solana/src/idl/) - -l, --language string Target language (go) (default "go") - -o, --out string Path to output directory (defaults to contracts/solana/src/generated/) + -l, --language string Target language: go, typescript (auto-detected from project files when omitted) + -o, --out string Path to output directory (defaults to contracts/solana/src/generated/ for Go, contracts/solana/ts/generated/ for TypeScript) -p, --project-root string Path to project root directory (defaults to current directory) ``` ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_init.md b/docs/cre_init.md index 6db85847..a44d4698 100644 --- a/docs/cre_init.md +++ b/docs/cre_init.md @@ -29,6 +29,7 @@ cre init [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_login.md b/docs/cre_login.md index 44a0b2ae..05012395 100644 --- a/docs/cre_login.md +++ b/docs/cre_login.md @@ -28,6 +28,7 @@ cre login [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_logout.md b/docs/cre_logout.md index 55a89250..8ca5bc92 100644 --- a/docs/cre_logout.md +++ b/docs/cre_logout.md @@ -19,6 +19,7 @@ cre logout [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_registry.md b/docs/cre_registry.md index 0ef2a741..5f6e2e3e 100644 --- a/docs/cre_registry.md +++ b/docs/cre_registry.md @@ -19,6 +19,7 @@ cre registry [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_registry_list.md b/docs/cre_registry_list.md index bbeb20bb..544fb134 100644 --- a/docs/cre_registry_list.md +++ b/docs/cre_registry_list.md @@ -33,6 +33,7 @@ cre registry list ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_secrets.md b/docs/cre_secrets.md index a2b48c07..59fd8132 100644 --- a/docs/cre_secrets.md +++ b/docs/cre_secrets.md @@ -13,13 +13,15 @@ cre secrets [optional flags] ### Options ``` - -h, --help help for secrets - --timeout duration Timeout for secrets operations (e.g. 30m, 2h, 48h). (default 48h0m0s) + -h, --help help for secrets + --secrets-auth string Authentication mode: onchain uses a wallet key for secrets on the on-chain registry; browser uses account credentials for secrets on the private registry. (default "onchain") + --timeout duration Timeout for secrets operations (e.g. 30m, 2h, 48h). (default 48h0m0s) ``` ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_secrets_create.md b/docs/cre_secrets_create.md index a3dd12f9..7d2a88a4 100644 --- a/docs/cre_secrets_create.md +++ b/docs/cre_secrets_create.md @@ -23,11 +23,13 @@ cre secrets create my-secrets.yaml ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags -R, --project-root string Path to the project root -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + --secrets-auth string Authentication mode: onchain uses a wallet key for secrets on the on-chain registry; browser uses account credentials for secrets on the private registry. (default "onchain") -T, --target string Use target settings from YAML config --timeout duration Timeout for secrets operations (e.g. 30m, 2h, 48h). (default 48h0m0s) -v, --verbose Run command in VERBOSE mode diff --git a/docs/cre_secrets_delete.md b/docs/cre_secrets_delete.md index c61bcd9a..93b81a0d 100644 --- a/docs/cre_secrets_delete.md +++ b/docs/cre_secrets_delete.md @@ -23,11 +23,13 @@ cre secrets delete my-secrets.yaml ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags -R, --project-root string Path to the project root -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + --secrets-auth string Authentication mode: onchain uses a wallet key for secrets on the on-chain registry; browser uses account credentials for secrets on the private registry. (default "onchain") -T, --target string Use target settings from YAML config --timeout duration Timeout for secrets operations (e.g. 30m, 2h, 48h). (default 48h0m0s) -v, --verbose Run command in VERBOSE mode diff --git a/docs/cre_secrets_execute.md b/docs/cre_secrets_execute.md index 5a8534dc..48b4d4e9 100644 --- a/docs/cre_secrets_execute.md +++ b/docs/cre_secrets_execute.md @@ -23,11 +23,13 @@ cre secrets execute 157364...af4d5.json ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags -R, --project-root string Path to the project root -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + --secrets-auth string Authentication mode: onchain uses a wallet key for secrets on the on-chain registry; browser uses account credentials for secrets on the private registry. (default "onchain") -T, --target string Use target settings from YAML config --timeout duration Timeout for secrets operations (e.g. 30m, 2h, 48h). (default 48h0m0s) -v, --verbose Run command in VERBOSE mode diff --git a/docs/cre_secrets_list.md b/docs/cre_secrets_list.md index 2214c55a..be9ca891 100644 --- a/docs/cre_secrets_list.md +++ b/docs/cre_secrets_list.md @@ -18,11 +18,13 @@ cre secrets list [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags -R, --project-root string Path to the project root -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + --secrets-auth string Authentication mode: onchain uses a wallet key for secrets on the on-chain registry; browser uses account credentials for secrets on the private registry. (default "onchain") -T, --target string Use target settings from YAML config --timeout duration Timeout for secrets operations (e.g. 30m, 2h, 48h). (default 48h0m0s) -v, --verbose Run command in VERBOSE mode diff --git a/docs/cre_secrets_update.md b/docs/cre_secrets_update.md index 526a2c12..da8542ad 100644 --- a/docs/cre_secrets_update.md +++ b/docs/cre_secrets_update.md @@ -23,11 +23,13 @@ cre secrets update my-secrets.yaml ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags -R, --project-root string Path to the project root -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + --secrets-auth string Authentication mode: onchain uses a wallet key for secrets on the on-chain registry; browser uses account credentials for secrets on the private registry. (default "onchain") -T, --target string Use target settings from YAML config --timeout duration Timeout for secrets operations (e.g. 30m, 2h, 48h). (default 48h0m0s) -v, --verbose Run command in VERBOSE mode diff --git a/docs/cre_templates.md b/docs/cre_templates.md index dc4f9df9..b908abf1 100644 --- a/docs/cre_templates.md +++ b/docs/cre_templates.md @@ -24,6 +24,7 @@ cre templates [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_templates_add.md b/docs/cre_templates_add.md index 3af5ce5c..910232d4 100644 --- a/docs/cre_templates_add.md +++ b/docs/cre_templates_add.md @@ -25,6 +25,7 @@ cre templates add smartcontractkit/cre-templates@main myorg/my-templates ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_templates_list.md b/docs/cre_templates_list.md index 4994db23..aa356e65 100644 --- a/docs/cre_templates_list.md +++ b/docs/cre_templates_list.md @@ -21,6 +21,7 @@ cre templates list [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_templates_remove.md b/docs/cre_templates_remove.md index d17742f6..60e4c930 100644 --- a/docs/cre_templates_remove.md +++ b/docs/cre_templates_remove.md @@ -25,6 +25,7 @@ cre templates remove smartcontractkit/cre-templates myorg/my-templates ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_update.md b/docs/cre_update.md index 5dbc7438..6a4e5701 100644 --- a/docs/cre_update.md +++ b/docs/cre_update.md @@ -2,6 +2,16 @@ Update the cre CLI to the latest version +### Synopsis + +Update the cre CLI to the latest version + +Release signatures are verified using the public key published by the CRE team. + +On Linux, the signature is verified using GPG. +On macOS, the signature is verified using codesign. +On Windows, the signature is verified using Authenticode. + ``` cre update [optional flags] ``` @@ -15,6 +25,7 @@ cre update [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_version.md b/docs/cre_version.md index 3409f9f7..43b5c776 100644 --- a/docs/cre_version.md +++ b/docs/cre_version.md @@ -19,6 +19,7 @@ cre version [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_whoami.md b/docs/cre_whoami.md index 1b8b9fd4..d83c0631 100644 --- a/docs/cre_whoami.md +++ b/docs/cre_whoami.md @@ -19,6 +19,7 @@ cre whoami [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow.md b/docs/cre_workflow.md index 25797b8a..6500d757 100644 --- a/docs/cre_workflow.md +++ b/docs/cre_workflow.md @@ -19,6 +19,7 @@ cre workflow [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags @@ -36,7 +37,7 @@ cre workflow [optional flags] * [cre workflow custom-build](cre_workflow_custom-build.md) - Converts an existing workflow to a custom (self-compiled) build * [cre workflow delete](cre_workflow_delete.md) - Deletes all versions of a workflow from the Workflow Registry * [cre workflow deploy](cre_workflow_deploy.md) - Deploys a workflow to the Workflow Registry contract -* [cre workflow get](cre_workflow_get.md) - Shows metadata for the workflow configured in workflow.yaml +* [cre workflow get](cre_workflow_get.md) - Show deployment health and recent execution for the workflow in workflow.yaml * [cre workflow hash](cre_workflow_hash.md) - Computes and displays workflow hashes * [cre workflow limits](cre_workflow_limits.md) - Manage simulation limits * [cre workflow list](cre_workflow_list.md) - Lists workflows deployed for your organization diff --git a/docs/cre_workflow_activate.md b/docs/cre_workflow_activate.md index 778789b4..7b52eabb 100644 --- a/docs/cre_workflow_activate.md +++ b/docs/cre_workflow_activate.md @@ -27,6 +27,7 @@ cre workflow activate ./my-workflow ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_build.md b/docs/cre_workflow_build.md index 845ca732..cc093e73 100644 --- a/docs/cre_workflow_build.md +++ b/docs/cre_workflow_build.md @@ -27,6 +27,7 @@ cre workflow build ./my-workflow ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_custom-build.md b/docs/cre_workflow_custom-build.md index d97a154e..d9f9426e 100644 --- a/docs/cre_workflow_custom-build.md +++ b/docs/cre_workflow_custom-build.md @@ -26,6 +26,7 @@ cre workflow custom-build ./my-workflow ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_delete.md b/docs/cre_workflow_delete.md index 4268dc7d..7b533b30 100644 --- a/docs/cre_workflow_delete.md +++ b/docs/cre_workflow_delete.md @@ -27,6 +27,7 @@ cre workflow delete ./my-workflow ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_deploy.md b/docs/cre_workflow_deploy.md index c89a3d4b..bd27b12a 100644 --- a/docs/cre_workflow_deploy.md +++ b/docs/cre_workflow_deploy.md @@ -34,6 +34,7 @@ cre workflow deploy ./my-workflow ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_get.md b/docs/cre_workflow_get.md index b93eeb69..cb648ba9 100644 --- a/docs/cre_workflow_get.md +++ b/docs/cre_workflow_get.md @@ -1,10 +1,10 @@ ## cre workflow get -Shows metadata for the workflow configured in workflow.yaml +Show deployment health and recent execution for the workflow in workflow.yaml ### Synopsis -Looks up the workflow whose name is configured for the selected --target in workflow.yaml and prints its metadata from the CRE platform. By default results are filtered to the workflow's configured deployment-registry; pass --all-registries to show matches from every registry. +Looks up the workflow whose name is configured for the selected --target in workflow.yaml and prints deployment health and the most recent execution from the CRE platform. By default resolution is scoped to the workflow's configured deployment-registry; pass --all-registries to resolve across every registry. ``` cre workflow get [optional flags] @@ -15,18 +15,22 @@ cre workflow get [optional flags] ``` cre workflow get ./my-workflow --target staging cre workflow get ./my-workflow --target staging --all-registries + cre workflow get ./my-workflow --target staging --output json ``` ### Options ``` - --all-registries Do not filter results by the workflow's deployment-registry + --all-registries Resolve the workflow across every registry instead of the configured deployment-registry -h, --help help for get + --json Output as JSON (shorthand for --output=json) + --output string Output format: "json" prints JSON to stdout ``` ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_hash.md b/docs/cre_workflow_hash.md index 09d60256..ea1c2235 100644 --- a/docs/cre_workflow_hash.md +++ b/docs/cre_workflow_hash.md @@ -32,6 +32,7 @@ cre workflow hash [optional flags] ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_limits.md b/docs/cre_workflow_limits.md index 886c6e8a..32547434 100644 --- a/docs/cre_workflow_limits.md +++ b/docs/cre_workflow_limits.md @@ -15,6 +15,7 @@ The limits command provides tools for managing workflow simulation limits. ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_limits_export.md b/docs/cre_workflow_limits_export.md index fa0ddbe6..b17aa7ae 100644 --- a/docs/cre_workflow_limits_export.md +++ b/docs/cre_workflow_limits_export.md @@ -26,6 +26,7 @@ cre workflow limits export > my-limits.json ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_list.md b/docs/cre_workflow_list.md index 88d78e6e..d67516fe 100644 --- a/docs/cre_workflow_list.md +++ b/docs/cre_workflow_list.md @@ -25,6 +25,7 @@ cre workflow list ``` -h, --help help for list --include-deleted Include workflows in DELETED status + --json Output as JSON (shorthand for --output=json) --output string Output format: "json" prints a JSON array to stdout --registry string Filter by registry ID from user context ``` @@ -32,6 +33,7 @@ cre workflow list ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_pause.md b/docs/cre_workflow_pause.md index 1c3ea36c..2117e703 100644 --- a/docs/cre_workflow_pause.md +++ b/docs/cre_workflow_pause.md @@ -27,6 +27,7 @@ cre workflow pause ./my-workflow ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/docs/cre_workflow_simulate.md b/docs/cre_workflow_simulate.md index c6eae86b..aeae2b2a 100644 --- a/docs/cre_workflow_simulate.md +++ b/docs/cre_workflow_simulate.md @@ -28,8 +28,9 @@ cre workflow simulate ./my-workflow --evm-tx-hash string EVM trigger transaction hash (0x...) -h, --help help for simulate --http-payload string HTTP trigger payload as JSON string or path to JSON file + --http-trigger-port int Port used by the local HTTP trigger server (default 2000) --limits string Production limits to enforce during simulation: 'default' for prod defaults, path to a limits JSON file (e.g. from 'cre workflow limits export'), or 'none' to disable (default "default") - --listen Listen for HTTP requests or supported log triggers and run the simulator for each match + --listen Listen for HTTP requests or supported log triggers and run the simulator for each match (not supported by cron) --no-config Simulate without a config file --skip-type-checks Skip TypeScript project typecheck during compilation (passes --skip-type-checks to cre-compile) --trigger-index int Index of the trigger to run (0-based) (default -1) @@ -39,6 +40,7 @@ cre workflow simulate ./my-workflow ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags @@ -50,4 +52,5 @@ cre workflow simulate ./my-workflow ### SEE ALSO -- [cre workflow](cre_workflow.md) - Manages workflows +* [cre workflow](cre_workflow.md) - Manages workflows + diff --git a/docs/cre_workflow_supported-chains.md b/docs/cre_workflow_supported-chains.md index c813d844..71b7d464 100644 --- a/docs/cre_workflow_supported-chains.md +++ b/docs/cre_workflow_supported-chains.md @@ -27,6 +27,7 @@ cre workflow supported-chains ### Options inherited from parent commands ``` + --allow-insecure-rpc Allow non-localhost HTTP RPC URLs (insecure) --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) -e, --env string Path to .env file which contains sensitive info --non-interactive Fail instead of prompting; requires all inputs via flags diff --git a/go.mod b/go.mod index e89f6e9e..56eebc87 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/smartcontractkit/cre-cli go 1.26.4 require ( - github.com/BurntSushi/toml v1.5.0 + github.com/BurntSushi/toml v1.6.0 github.com/Masterminds/semver/v3 v3.5.0 github.com/andybalholm/brotli v1.2.1 github.com/aptos-labs/aptos-go-sdk v1.13.0 @@ -15,32 +15,33 @@ require ( github.com/dave/jennifer v1.7.1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/denisbrodbeck/machineid v1.0.1 - github.com/ethereum/go-ethereum v1.17.3 + github.com/ethereum/go-ethereum v1.17.4 github.com/gagliardetto/anchor-go v1.0.0 github.com/gagliardetto/binary v0.8.0 github.com/gagliardetto/solana-go v1.13.0 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 - github.com/go-playground/validator/v10 v10.30.2 + github.com/go-playground/validator/v10 v10.30.3 github.com/google/uuid v1.6.0 github.com/jarcoal/httpmock v1.4.1 github.com/jedib0t/go-pretty/v6 v6.6.5 github.com/joho/godotenv v1.5.1 github.com/machinebox/graphql v0.2.2 github.com/pkg/errors v0.9.1 - github.com/rs/zerolog v1.34.0 - github.com/smartcontractkit/chain-selectors v1.0.101 + github.com/rs/zerolog v1.35.1 + github.com/smartcontractkit/chain-selectors v1.0.104 github.com/smartcontractkit/chainlink-aptos v0.0.0-20260609211101-71d38bd6a0a9 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260610184803-96d1e031407b + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260713194119-2689c5708c8b github.com/smartcontractkit/chainlink-common/keystore v1.2.0 github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260512150409-b4068bf735e6 - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260609153034-c8423a41ef9a - github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528221400-84746b70eeeb + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707195416-ca350beacd4b + github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260630073003-fb8da7229930 + github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260702134003-26b16cb03cdc github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5 github.com/smartcontractkit/chainlink/deployment v0.0.0-20260521170940-67f9a4b233f8 - github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260611195012-46c170c777fd - github.com/smartcontractkit/cre-sdk-go v1.9.0-capdev.1.0.20260605151643-add8be700599 - github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm v1.0.0-beta.10-capdev.1.0.20260605151643-add8be700599 + github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260714140112-3f1199c50293 + github.com/smartcontractkit/cre-sdk-go v1.16.0-capdev.1 + github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm v1.0.0-beta.10-capdev.1.0.20260708154710-7c3f06878b06 github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260612142557-01f4db8d7d47 github.com/smartcontractkit/mcms v0.45.0 github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c @@ -50,8 +51,10 @@ require ( github.com/stretchr/testify v1.11.1 github.com/test-go/testify v1.1.4 go.uber.org/zap v1.28.0 - golang.org/x/mod v0.36.0 - golang.org/x/term v0.43.0 + golang.org/x/crypto v0.54.0 + golang.org/x/mod v0.37.0 + golang.org/x/term v0.45.0 + golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 @@ -78,6 +81,7 @@ require ( github.com/NethermindEth/juno v0.12.5 // indirect github.com/NethermindEth/starknet.go v0.8.0 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260416073033-7c2071eaa8d4 // indirect + github.com/Unheilbar/anchor-go v1.0.3 // indirect github.com/VictoriaMetrics/fastcache v1.13.0 // indirect github.com/XSAM/otelsql v0.42.0 // indirect github.com/apache/arrow-go/v18 v18.6.0 // indirect @@ -102,8 +106,9 @@ require ( github.com/buger/jsonparser v1.2.0 // indirect github.com/buraksezer/consistent v0.10.0 // indirect github.com/bytecodealliance/wasmtime-go/v28 v28.0.0 // indirect - github.com/bytedance/sonic v1.12.3 // indirect - github.com/bytedance/sonic/loader v0.2.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -118,8 +123,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudevents/sdk-go/binding/format/protobuf/v2 v2.16.2 // indirect github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/cockroachdb/errors v1.11.3 // indirect github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect @@ -163,13 +167,14 @@ require ( github.com/fatih/color v1.19.0 // indirect github.com/fbsobreira/gotron-sdk v0.0.0-20250403083053-2943ce8c759b // indirect github.com/ferranbt/fastssz v0.1.4 // indirect + github.com/fjl/jsonw v0.1.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gagliardetto/treeout v0.1.4 // indirect github.com/getsentry/sentry-go v0.27.0 // indirect github.com/gin-contrib/sessions v0.0.5 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-gonic/gin v1.10.1 // indirect github.com/go-co-op/gocron/v2 v2.18.0 // indirect github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect @@ -202,6 +207,7 @@ require ( github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go v1.3.0 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.10 // indirect + github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect @@ -252,14 +258,13 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 // indirect github.com/mitchellh/pointerstructure v1.2.0 // indirect - github.com/moby/moby/api v1.54.2 // indirect github.com/moby/moby/client v0.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect @@ -279,16 +284,15 @@ require ( github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect - github.com/pion/dtls/v2 v2.2.12 // indirect - github.com/pion/logging v0.2.2 // indirect - github.com/pion/stun/v2 v2.0.0 // indirect - github.com/pion/transport/v2 v2.2.10 // indirect - github.com/pion/transport/v3 v3.0.1 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/stun/v3 v3.1.2 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/common v1.20.99 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect @@ -310,24 +314,25 @@ require ( github.com/smartcontractkit/chainlink-automation v0.8.1 // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260511195239-0f6e1b177fc7 // indirect - github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0 // indirect - github.com/smartcontractkit/chainlink-data-streams v0.1.15-0.20260522094612-5f9f748bd87a // indirect - github.com/smartcontractkit/chainlink-deployments-framework v0.105.0 // indirect - github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260609161557-8ceae53b8ab1 // indirect + github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62 // indirect + github.com/smartcontractkit/chainlink-data-streams v0.1.15-0.20260707105132-2da5d31f22fc // indirect + github.com/smartcontractkit/chainlink-deployments-framework v0.109.0 // indirect + github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260623170329-4577ef4ba0ae // indirect github.com/smartcontractkit/chainlink-evm/contracts/cre/gobindings v0.0.0-20260403151002-2c91155b5501 // indirect github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c // indirect github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c // indirect github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260521164805-26d78d5e1243 // indirect - github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260521164805-26d78d5e1243 // indirect + github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260625152110-9afcf56e4053 // indirect github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 // indirect github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20260512230622-65f10f4cd305 // indirect - github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260512230622-65f10f4cd305 // indirect + github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 // indirect github.com/smartcontractkit/chainlink-protos/ring/go v0.0.0-20260331131315-f08a616d8dcd // indirect github.com/smartcontractkit/chainlink-protos/storage-service v0.3.0 // indirect github.com/smartcontractkit/chainlink-protos/svr v1.2.0 // indirect - github.com/smartcontractkit/chainlink-sui v0.0.0-20260527160341-aa3adc0abf67 // indirect - github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260608211110-ed43ab034a6f // indirect + github.com/smartcontractkit/chainlink-sui v0.0.0-20260624134342-6bfb9c92859d // indirect + github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.6-0.20260630120514-36abe27604df // indirect + github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260629213843-c52e07523035 // indirect github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20260408092456-3c6369888d4a // indirect github.com/smartcontractkit/cld-changesets v0.4.0 // indirect github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad // indirect @@ -349,7 +354,7 @@ require ( github.com/tendermint/go-amino v0.16.0 // indirect github.com/theodesp/go-heaps v0.0.0-20190520121037-88e35354fe0a // indirect github.com/tidwall/gjson v1.18.0 // indirect - github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect @@ -359,6 +364,7 @@ require ( github.com/ugorji/go/codec v1.2.12 // indirect github.com/urfave/cli/v2 v2.27.7 // indirect github.com/valyala/fastjson v1.6.10 // indirect + github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect @@ -376,9 +382,9 @@ require ( go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect @@ -387,12 +393,12 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect - go.opentelemetry.io/otel/log v0.19.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel/log v0.20.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -400,21 +406,19 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect - golang.org/x/arch v0.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/arch v0.22.0 // indirect golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect - google.golang.org/grpc v1.81.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.82.0 // indirect gopkg.in/guregu/null.v4 v4.0.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gotest.tools/v3 v3.5.2 // indirect @@ -428,3 +432,5 @@ require ( replace github.com/gogo/protobuf => github.com/regen-network/protobuf v1.3.3-alpha.regen.1 replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014143056-a0c6328c91e9 + +replace github.com/smartcontractkit/chainlink-sui => github.com/smartcontractkit/chainlink-sui v0.0.0-20260527160341-aa3adc0abf67 diff --git a/go.sum b/go.sum index 0fc08141..c24856f9 100644 --- a/go.sum +++ b/go.sum @@ -38,8 +38,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6 github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go v3.2.0+incompatible h1:qSG2N4FghB1He/r2mFrWKCaL7dXCilEuNEeAn20fdD4= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/zstd v1.5.6 h1:LbEglqepa/ipmmQJUDnSsfvA8e8IStVcGaFWDuxvGOY= @@ -59,6 +59,8 @@ github.com/NethermindEth/starknet.go v0.8.0 h1:mGh7qDWrvuXJPcgGJP31DpifzP6+Ef2gt github.com/NethermindEth/starknet.go v0.8.0/go.mod h1:slNA8PxtxA/0LQv0FwHnL3lHFDNhVZfTK6U2gjVb7l8= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260416073033-7c2071eaa8d4 h1:/97whAzwYxMNHXeTfhAtCRzNCpyblmxCtSYpsfzCszM= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260416073033-7c2071eaa8d4/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/Unheilbar/anchor-go v1.0.3 h1:rIZ9FU7L+OazdTR+FB6Q2Fe65LvsDXA649vR3LJRJl0= +github.com/Unheilbar/anchor-go v1.0.3/go.mod h1:v5AdT7FpHKNcIppfr3bix/ZP3Kr8oOzmxSxEWsCMEd0= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= @@ -191,11 +193,12 @@ github.com/buraksezer/consistent v0.10.0 h1:hqBgz1PvNLC5rkWcEBVAL9dFMBWz6I0VgUCW github.com/buraksezer/consistent v0.10.0/go.mod h1:6BrVajWq7wbKZlTOUPs/XVfR8c0maujuPowduSpZqmw= github.com/bytecodealliance/wasmtime-go/v28 v28.0.0 h1:aBU8cexP2rPZ0Qz488kvn2NXvWZHL2aG1/+n7Iv+xGc= github.com/bytecodealliance/wasmtime-go/v28 v28.0.0/go.mod h1:4OCU0xAW9ycwtX4nMF4zxwgJBJ5/0eMfJiHB0wAmkV4= -github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU= -github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= -github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= @@ -252,10 +255,8 @@ github.com/cloudevents/sdk-go/binding/format/protobuf/v2 v2.16.2 h1:ydUjnKn4RoCe github.com/cloudevents/sdk-go/binding/format/protobuf/v2 v2.16.2/go.mod h1:Bny999RuVUtNjzTGa9HCHpXjrLGMipJVq5kqVpudBl0= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -293,7 +294,6 @@ github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/ github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cosmos/btcutil v1.0.5 h1:t+ZFcX77LpKtDBhjucvnOH8C2l2ioGsBNEQ3jef8xFk= github.com/cosmos/btcutil v1.0.5/go.mod h1:IyB7iuqZMJlthe2tkIFL33xPyzbFYP0XVdS8P5lUPis= github.com/cosmos/cosmos-db v1.1.1 h1:FezFSU37AlBC8S98NlSagL76oqBRWq/prTPvFcEJNCM= @@ -405,8 +405,8 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.7 h1:aat3CuITdDbPC6pmEGRT0zJ5eOxzrZj8TJT5 github.com/ethereum/c-kzg-4844/v2 v2.1.7/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= -github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= +github.com/ethereum/go-ethereum v1.17.4 h1:uA4q+qiLp7QImBsjdRbINu8iX6OEVmj4DPc5/E5Fsxc= +github.com/ethereum/go-ethereum v1.17.4/go.mod h1:qMdgwqqRAen+aT8P7KKQKi0Qt6RzG4cfejVAbCpJgqA= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/failsafe-go/failsafe-go v0.9.0 h1:w0g7iv48RpQvV3UH1VlgUnLx9frQfCwI7ljnJzqEhYg= @@ -418,6 +418,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/fjl/jsonw v0.1.0 h1:V3MyR79fjLpn/+bMgvegdGUIhoJOzjmqWcKDgcOmY1I= +github.com/fjl/jsonw v0.1.0/go.mod h1:2KMLevM6FXEJnfhtk7naXu9vZdVfOma1GlnGdPRlumU= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -456,8 +458,8 @@ github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfm github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY= github.com/gin-contrib/size v0.0.0-20230212012657-e14a14094dc4 h1:Z9J0PVIt1PuibOShaOw1jH8hUYz+Ak8NLsR/GI0Hv5I= github.com/gin-contrib/size v0.0.0-20230212012657-e14a14094dc4/go.mod h1:CEPcgZiz8998l9E8fDm16h8UfHRL7b+5oG0j/0koeVw= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= @@ -529,8 +531,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= @@ -550,7 +552,6 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -642,6 +643,8 @@ github.com/grafana/pyroscope-go v1.3.0 h1:t3Jehad8vvqN4oRAB0LdmfQ5ZSUXQw3asoft+K github.com/grafana/pyroscope-go v1.3.0/go.mod h1:XA7I3usNx+UdjOZfQnl1WV8y924vsJo9KIVrKB+9jx4= github.com/grafana/pyroscope-go/godeltaprof v0.1.10 h1:dvhndEbyavTb59vFCd6PsrAG5qi69/qZZtegh/TJKSY= github.com/grafana/pyroscope-go/godeltaprof v0.1.10/go.mod h1:XnWRGg2XO5uxZdiz1rfeJH6w1eZ+YICCBVXNWOfH86g= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/graph-gophers/dataloader v5.0.0+incompatible h1:R+yjsbrNq1Mo3aPG+Z/EKYrXrXXUNJHOgbRt+U6jOug= github.com/graph-gophers/dataloader v5.0.0+incompatible/go.mod h1:jk4jk0c5ZISbKaMe8WsVopGB5/15GvGHMdMdPtwlRp4= github.com/graph-gophers/graphql-go v1.5.0 h1:fDqblo50TEpD0LY7RXk/LFVYEVqo3+tXMNMPSVXA1yc= @@ -823,10 +826,8 @@ github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6 github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -878,21 +879,18 @@ github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcncea github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= @@ -1020,19 +1018,14 @@ github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= -github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= -github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= -github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= -github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= -github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= -github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= -github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= -github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= -github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= -github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= -github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY= +github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -1064,8 +1057,8 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/common v1.20.99 h1:vZEybF3CT0t6L0UjsOtHRML7vuIglHocmvJMMH/se4M= +github.com/prometheus/common v1.20.99/go.mod h1:VX44Tebe4qpuTK+MQWg25h4fJGKBqzObSdxuB7y8K/Y= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= @@ -1093,11 +1086,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -1144,36 +1136,36 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9LsA7vTMPv+0n7ClhSFnZFAk= github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY= -github.com/smartcontractkit/chain-selectors v1.0.101 h1:TF4ma9h3QeyIZ8XoEmgI5lrUvZfzHAz8tfR0pV0+GCA= -github.com/smartcontractkit/chain-selectors v1.0.101/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= +github.com/smartcontractkit/chain-selectors v1.0.104 h1:/n9pPGM5W/+r1eHoWZv4VwX9LNS1af4+ICyhM8zKRNM= +github.com/smartcontractkit/chain-selectors v1.0.104/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= github.com/smartcontractkit/chainlink-aptos v0.0.0-20260609211101-71d38bd6a0a9 h1:O5t0naKzEHQs7PtK1iMWqPbTqVleBPJwUjGEwVj7BVU= github.com/smartcontractkit/chainlink-aptos v0.0.0-20260609211101-71d38bd6a0a9/go.mod h1:QzCiWXguySSjbcZfHclM2DS3rGTKAHZLNJOVreJY93o= +github.com/smartcontractkit/chainlink-aptos/codec v0.0.0-20260709173141-5c4e651312f7 h1:y6JwYHEczhFozX0DrzXobmJcw/+jhND7SopNIF8NN2k= +github.com/smartcontractkit/chainlink-aptos/codec v0.0.0-20260709173141-5c4e651312f7/go.mod h1:xvU0RyD/jMmKnZA2eA3obYhTV80pBERK5ECwBZB2CuY= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= -github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 h1:p0nFrTYrOQzDhWYm6suaM5CoWiXV5NV7llHnp6/Kn/8= -github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1/go.mod h1:1XxxpkgCmG/z6y30yRuVrcxre6zixIVX3xzi706Db/8= -github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm v0.0.0-20260408145530-22e2d05695cd h1:Jtw6p5iisjXZyFOcBvWh6PDQKtvryrRU2JMmezdutjo= -github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm v0.0.0-20260408145530-22e2d05695cd/go.mod h1:zLqdD2kBX7NsntBneclb2yrHhjFaJdoyA8dK5eimlrE= -github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260506144252-c100eabfda74 h1:uRvSogvgIi3JhQGNYGmRr3GqTSbD0yG1jSgO7lHL5z4= -github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260506144252-c100eabfda74/go.mod h1:LDCeKlQ6Ne0DYjI2RiqY2ZIO449FzjSHGc04TLszh68= +github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260625091148-e5618f5682ee h1:YAE9gMuCsjp3toJXBQge7pvSZhsFCv9GakTEjjoiE50= +github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260625091148-e5618f5682ee/go.mod h1:2xVZJ3o9udYFeJhwyHXAMlNhptJ99uoiGjzfOicYNX8= +github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260624154507-ea7ff77a0ddb h1:bsokSvqbT4kewt+JpIySMR3pTlzAhFBxiWcXkWpCFMY= +github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260624154507-ea7ff77a0ddb/go.mod h1:FbFlai6KYc1SKjJxfU//6iLruq4SyHi4PYS1SRZYoM4= github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc h1:mvobZx5JV5PhG/9IXPReV+8mAGnupl0HIWQZ43zxzd4= github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc/go.mod h1:gzCVLUlNov/zFXSC7G6zcGkZU1IfNOHaakbAPDe5Woc= github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260511195239-0f6e1b177fc7 h1:H4elXlsDnREQpx8JESKxIuHzMCwGlJbL5+MpFCoLZZc= github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260511195239-0f6e1b177fc7/go.mod h1:67YbnoglYD61Pz/jTVCgav9wFq7S35OU8UyQSvPllRw= -github.com/smartcontractkit/chainlink-ccv v0.0.2-0.20260428133800-3b1484e8b1fd h1:IMopuENFVS63AerRELdfWo6o60UNUidcldJOxJLmk24= -github.com/smartcontractkit/chainlink-ccv v0.0.2-0.20260428133800-3b1484e8b1fd/go.mod h1:SBN8Urnh5sQvrQRbSo1Nr8coWatHg8LZoPw3R/42sho= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260610184803-96d1e031407b h1:UMQ+MwHI341h+yARqeKmY/cagkB/dH0J34aMoJG00io= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260610184803-96d1e031407b/go.mod h1:GlEVw7ziizXoMfzl1onNSwansrVBLHhj5gUJlGQpb4I= +github.com/smartcontractkit/chainlink-ccv v0.0.2-0.20260706093831-dad26b360094 h1:uMn1w/n95p+pSxK6hYNqji/sDaab8D0Cxuph9qUkM2g= +github.com/smartcontractkit/chainlink-ccv v0.0.2-0.20260706093831-dad26b360094/go.mod h1:3XQmy2zQHrmufebP7dM+x595+gghxzyIKxK9wv91Rh8= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260713194119-2689c5708c8b h1:tQz44wP1HL/Y+9iX+gEmuDeoBEftV0yDvhgfuiG1Xv0= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260713194119-2689c5708c8b/go.mod h1:snfVBRRQTpC2x5O3bQHZe9SvJX5yv/SbG8oHkJTKLtE= github.com/smartcontractkit/chainlink-common/keystore v1.2.0 h1:1BH/b14CkGjArfzznlioQpIJiynECWVT48JUP9E277U= github.com/smartcontractkit/chainlink-common/keystore v1.2.0/go.mod h1:9R/74vN+bJ5PbkOyM/pUy/AeAZaRwYb/k4XPeXcbDio= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0 h1:NExKM/D0HneOq/N5LGTbkV4VOa0UHCvfTNEb4GqYpto= -github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0/go.mod h1:HmUyH2oD9m+GRpKq7q3vuRnm1F2Uczf/Nd1v3ipMSK8= -github.com/smartcontractkit/chainlink-data-streams v0.1.15-0.20260522094612-5f9f748bd87a h1:8bIqv4r7SgDWkXL2Qz/Ijw+YjZY1uroIte3E2v2keVk= -github.com/smartcontractkit/chainlink-data-streams v0.1.15-0.20260522094612-5f9f748bd87a/go.mod h1:dF5JiHWueHjYguUUUrFeb03MkcDqha/tssEkqTkgzp4= -github.com/smartcontractkit/chainlink-deployments-framework v0.105.0 h1:Vp4EwkvxcBzgahIZdbWyCExDXLha93cS63xvwd2xwx8= -github.com/smartcontractkit/chainlink-deployments-framework v0.105.0/go.mod h1:xFLOOpIz7vqqno4YngHZlF9MKqk8rnvQa9adVElUXaE= -github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260609161557-8ceae53b8ab1 h1:VdJBtNmasHzISQQF0k0LHFh44WDKO7S00VyaT7qykuc= -github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260609161557-8ceae53b8ab1/go.mod h1:3ifVi4ueXLo5+pSVpvmaJBbCYhTFVx9qxp6bIU11QQc= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62 h1:o7vfwNQjQbMKQ9YsZFQOxvU7RMXD/wKnZsX5N9sDS3w= +github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62/go.mod h1:HmUyH2oD9m+GRpKq7q3vuRnm1F2Uczf/Nd1v3ipMSK8= +github.com/smartcontractkit/chainlink-data-streams v0.1.15-0.20260707105132-2da5d31f22fc h1:9xj2uDKZ4JSvJOfjT1LwoK1M9Ux+NUEE+FTMl4KqYsE= +github.com/smartcontractkit/chainlink-data-streams v0.1.15-0.20260707105132-2da5d31f22fc/go.mod h1:dF5JiHWueHjYguUUUrFeb03MkcDqha/tssEkqTkgzp4= +github.com/smartcontractkit/chainlink-deployments-framework v0.109.0 h1:sURmdL2OnO55SETWIFzIEqQH7RCiHJyW7on8HvfnLY8= +github.com/smartcontractkit/chainlink-deployments-framework v0.109.0/go.mod h1:ubpvoLoRdru8IQHw3TFr7KthbjYpAwmiRmvvNCf2daA= +github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260623170329-4577ef4ba0ae h1:VrOaSNVhElURmca9ZVVyKlSiOg9Hz372+oFJD38fYRo= +github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260623170329-4577ef4ba0ae/go.mod h1:3ifVi4ueXLo5+pSVpvmaJBbCYhTFVx9qxp6bIU11QQc= github.com/smartcontractkit/chainlink-evm/contracts/cre/gobindings v0.0.0-20260403151002-2c91155b5501 h1:QJiXTG9CmaQAuMRn5JGi+Jhji7fSkehVnKpjc8oNJJY= github.com/smartcontractkit/chainlink-evm/contracts/cre/gobindings v0.0.0-20260403151002-2c91155b5501/go.mod h1:4cT1BeNF8DAn6In9zr3LayVCv1KzFeuxT7zcuNkfIb0= github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260512150409-b4068bf735e6 h1:JFo7C3FilwhfwGBLAyj2umbL+P4QxGmVi/b8yt9kqvI= @@ -1186,8 +1178,8 @@ github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1 github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:kGprqyjsz6qFNVszOQoHc24wfvCjyipNZFste/3zcbs= github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260521164805-26d78d5e1243 h1:vaFBupfFfImQgqOeuC7Muk2GflbYP6Gpi0Y/TLroFU8= github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260521164805-26d78d5e1243/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4= -github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260521164805-26d78d5e1243 h1:71PGTkjdFZ0JrloEC2Fs8eHl1b1gmUuH+bq7q23usKk= -github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260521164805-26d78d5e1243/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ= +github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260625152110-9afcf56e4053 h1:lW5ccLVGuDG8/VojIRMpjguVfXjA7UJBFYS0POWvrzs= +github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260625152110-9afcf56e4053/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4/go.mod h1:HHGeDUpAsPa0pmOx7wrByCitjQ0mbUxf0R9v+g67uCA= github.com/smartcontractkit/chainlink-protos/chainlink-ccv/committee-verifier v0.0.0-20251211142334-5c3421fe2c8d h1:VYoBBNnQpZ5p+enPTl8SkKBRaubqyGpO0ul3B1np++I= @@ -1196,22 +1188,24 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-2026 github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e/go.mod h1:rZV/gLc1wlSp2r5oXN09iOrlyZPFX4iK+cqoSW2k5dc= github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d h1:pKCyW7BYzO5GThFNlXZY0Azx/yOnI4b5GeuLeU23ie0= github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:ATjAPIVJibHRcIfiG47rEQkUIOoYa6KDvWj3zwCAw6g= +github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-rules v0.0.0-20260505131349-78e491b80735 h1:TisH4dYVSh33CZI7qzIkGZx7RzhnynQuQEAdhjnzvb4= +github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-rules v0.0.0-20260505131349-78e491b80735/go.mod h1:zAJq6Tpkx5AdFUwW67dIYnW+Bdf50drCCpMR81Qxb4E= github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d h1:AJy55QJ/pBhXkZjc7N+ATnWfxrcjq9BI9DmdtdjwDUQ= github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260609153034-c8423a41ef9a h1:alnfvQgCKPFqsfijZQnr6Sbus2GT5YZ9BT/KzVrbszE= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260609153034-c8423a41ef9a/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707195416-ca350beacd4b h1:NR5EdsI2wNUV9awkmZyVSWos3JAK+2uSgjkrWbSRWXo= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707195416-ca350beacd4b/go.mod h1:/i8hjTPFdVWHiY+QjeSiVS2Z3GB3WAZznGgXHstC02E= github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 h1:SG+wAsNyAcA6Kk19ljuxi3HK9Ll2lpHik8OKoY4x7A0= github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20260512230622-65f10f4cd305 h1:NJdGFhzT6zMaTod4QkBqVD2sg0I25iw1boOYtTpEwRo= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20260512230622-65f10f4cd305/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= -github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260512230622-65f10f4cd305 h1:bnSl5p3mFekSJ6QcbZ1TmHn2ffYiX8xk6hNzVmyhstQ= -github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260512230622-65f10f4cd305/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= -github.com/smartcontractkit/chainlink-protos/op-catalog v0.0.4 h1:AEnxv4HM3WD1RbQkRiFyb9cJ6YKAcqBp1CpIcFdZfuo= -github.com/smartcontractkit/chainlink-protos/op-catalog v0.0.4/go.mod h1:PjZD54vr6rIKEKQj6HNA4hllvYI/QpT+Zefj3tqkFAs= -github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.1-0.20260528221400-84746b70eeeb h1:G8X3SR21VYAHWkDkNGZCjsrWrLJoVmXMpYBa2KKK3GU= -github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.1-0.20260528221400-84746b70eeeb/go.mod h1:m/A3lqD7ms/RsQ9BT5P2uceYY0QX5mIt4KQxT2G6qEo= +github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 h1:/vkKPJoweLkRd56V4YHGRAtTG4+/JAlgklGEfvH6l4c= +github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= +github.com/smartcontractkit/chainlink-protos/op-catalog v0.1.0 h1:hGEJFD2X3oNIPXQbtIPxCJyg5CcKglRCYBmESS+gmeQ= +github.com/smartcontractkit/chainlink-protos/op-catalog v0.1.0/go.mod h1:PjZD54vr6rIKEKQj6HNA4hllvYI/QpT+Zefj3tqkFAs= +github.com/smartcontractkit/chainlink-protos/orchestrator v0.11.0 h1:NXKTdIESAiCkVnPS6dyZP+NXVek3GzXa6P4uFAs0o8Y= +github.com/smartcontractkit/chainlink-protos/orchestrator v0.11.0/go.mod h1:m/A3lqD7ms/RsQ9BT5P2uceYY0QX5mIt4KQxT2G6qEo= github.com/smartcontractkit/chainlink-protos/ring/go v0.0.0-20260331131315-f08a616d8dcd h1:7DURXB3+Qf9REr3XA+q0FNyZO3CSAeSgJvNaek/GiZI= github.com/smartcontractkit/chainlink-protos/ring/go v0.0.0-20260331131315-f08a616d8dcd/go.mod h1:aifeP3SnsVrO1eSN5Smur3iHjAmi3poaLt6TAbgK0Hw= github.com/smartcontractkit/chainlink-protos/rmn/v1.6/go v0.0.0-20250131130834-15e0d4cde2a6 h1:L6KJ4kGv/yNNoCk8affk7Y1vAY0qglPMXC/hevV/IsA= @@ -1220,30 +1214,34 @@ github.com/smartcontractkit/chainlink-protos/storage-service v0.3.0 h1:B7itmjy+C github.com/smartcontractkit/chainlink-protos/storage-service v0.3.0/go.mod h1:h6kqaGajbNRrezm56zhx03p0mVmmA2xxj7E/M4ytLUA= github.com/smartcontractkit/chainlink-protos/svr v1.2.0 h1:7jjgqRgORQS/ikL3z0ZgJy95pzjhR9LuU1TVWg4BZ78= github.com/smartcontractkit/chainlink-protos/svr v1.2.0/go.mod h1:TcOliTQU6r59DwG4lo3U+mFM9WWyBHGuFkkxQpvSujo= -github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528221400-84746b70eeeb h1:mlN8zK1UzDIBYtKSILQ4gci9MFwo42QFtGV1tWddMyk= -github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528221400-84746b70eeeb/go.mod h1:GTpDgyK0OObf7jpch6p8N281KxN92wbB8serZhU9yRc= +github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260630073003-fb8da7229930 h1:x2nm4nDoC//WGQRPrInDmBH2/lTN1qAI/IGDQ3gAi7A= +github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260630073003-fb8da7229930/go.mod h1:GTpDgyK0OObf7jpch6p8N281KxN92wbB8serZhU9yRc= +github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260702134003-26b16cb03cdc h1:A4e4FLqwY+WEOPkmFGfSLnbUC8qwUNRVdp5e7u4vj6c= +github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260702134003-26b16cb03cdc/go.mod h1:tZ5P6RKFsGpebJA6vWzMxPij52VuUdPu25E90W8mHbk= github.com/smartcontractkit/chainlink-sui v0.0.0-20260527160341-aa3adc0abf67 h1:NNvPOgvf5vbOYVLxLST+5E88iPOAnpmzZGPihEx8DFc= github.com/smartcontractkit/chainlink-sui v0.0.0-20260527160341-aa3adc0abf67/go.mod h1:k1HSbHyPaQWPOj6lXDIAe04EuwbC5ge1nK+cpG2E8hE= -github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.1 h1:wZd5hIQRcQaq3FgW1lg/4ilk68Id6cxYKFNU9iTnugs= -github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.1/go.mod h1:wxgGfrJpzIdC1wyMJEGOfN4H4yPQTZD/DdrMRBxA0io= +github.com/smartcontractkit/chainlink-sui/codec v0.0.0-20260713202800-ac352a2c68f0 h1:uu9Q34CyiZ1t841WvuA5V4/wTg0S3EA5YYoqFjla8Xk= +github.com/smartcontractkit/chainlink-sui/codec v0.0.0-20260713202800-ac352a2c68f0/go.mod h1:kiteWrjRjMjtZc3UqvjgtmF7JQpw1VttjCmQ083HJro= +github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.6-0.20260630120514-36abe27604df h1:a5PvGrH0Wgk5GtXWSXxlBfNPlyZ8x6FKDQ4UbNa6/Qk= +github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.6-0.20260630120514-36abe27604df/go.mod h1:nyOjn4ADJGqRMe3+4ZXSV+J/7nWb1H2Vx8Qk57eLRYA= github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5 h1:RwZXxdIAOyjp6cwc9Quxgr38k8r7ACz+Lxh9o/A6oH0= github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5/go.mod h1:kHYJnZUqiPF7/xN5273prV+srrLJkS77GbBXHLKQpx0= -github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260608211110-ed43ab034a6f h1:HXbJWGqJX14BmsVlSww6vcuOcIPbsP+vUe3dWtwA740= -github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260608211110-ed43ab034a6f/go.mod h1:4e/rmLNsaA39KZYQ9BvBbyf2fMsYtf7Da/FX9YEwgtw= +github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260629213843-c52e07523035 h1:6kOtwaTuvnXatZrcEBpETiRPaSS4nh9mXBPiGPXRi9w= +github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260629213843-c52e07523035/go.mod h1:4e/rmLNsaA39KZYQ9BvBbyf2fMsYtf7Da/FX9YEwgtw= github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20260408092456-3c6369888d4a h1:Xu8iBnBQEibWIXTCwKYf8okXjFtzJ0KochjL03h+T40= github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20260408092456-3c6369888d4a/go.mod h1:1eaXR+Fe6TlpP+CKXozfYlFM8QgN/N5C7OMvTRWNT8I= github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014143056-a0c6328c91e9 h1:/Q1gD5gI0glBMztVH9XUVci3aOy8h+qTDV6o42MsqMM= github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014143056-a0c6328c91e9/go.mod h1:ea1LESxlSSOgc2zZBqf1RTkXTMthHaspdqUHd7W4lF0= github.com/smartcontractkit/chainlink/deployment v0.0.0-20260521170940-67f9a4b233f8 h1:xPDpOmxTlT2RW+pPUElGa0/y02V/MAHIPD8DEtEBLfE= github.com/smartcontractkit/chainlink/deployment v0.0.0-20260521170940-67f9a4b233f8/go.mod h1:WL7W/YQO5pQ1Nexm4lvd/SztM2OzbhaIhJKyrfMU8QQ= -github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260611195012-46c170c777fd h1:rkng0p7WvOBkdbWRzQtanC5iX/rq81Epr6pyu21LaMQ= -github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260611195012-46c170c777fd/go.mod h1:hyluWzB3Rr8BXbSqONm91Krn6XpddpHKe5eW7XiYcMI= +github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260714140112-3f1199c50293 h1:6ymqUjbmRTozHYwQyUNTsUvyuwEnyN/EYi3GR5pvorY= +github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260714140112-3f1199c50293/go.mod h1:u/rOsv+bjHlPDQN3hzMelQYN83UZC7bjtrOJbarcB/k= github.com/smartcontractkit/cld-changesets v0.4.0 h1:S6yNRj6FssyyKbxLHTbC9X9U4qsph17xiiBBT6DGyNE= github.com/smartcontractkit/cld-changesets v0.4.0/go.mod h1:4wOfnbSP8Ior/75QWLbtDntamSA/81SYcXzctBSx9CY= -github.com/smartcontractkit/cre-sdk-go v1.9.0-capdev.1.0.20260605151643-add8be700599 h1:1o8AZCRDn6jUOL5TdJdTP6QNjCsxROB7CrtyKTR7nhk= -github.com/smartcontractkit/cre-sdk-go v1.9.0-capdev.1.0.20260605151643-add8be700599/go.mod h1:B9bBug58zdooG8ylplFLDnto1hapkCYyVO+FZar0hTM= -github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm v1.0.0-beta.10-capdev.1.0.20260605151643-add8be700599 h1:Yo0UwRJfFyHxicZfSACD97CUSsuK8jlt5mRi6C1mibc= -github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm v1.0.0-beta.10-capdev.1.0.20260605151643-add8be700599/go.mod h1:byZOtes8gvsVZFsG0NOzG59rlAUV+ssf/a3j+WepUC0= +github.com/smartcontractkit/cre-sdk-go v1.16.0-capdev.1 h1:mkTzIeSIqLpg6snIiiLPnNAa1LHPjs/DH2XLXPeC0uQ= +github.com/smartcontractkit/cre-sdk-go v1.16.0-capdev.1/go.mod h1:eu0r6WVOHf8WbyxKkKFxwl8WRDwBpPNQng5R8QbJTxQ= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm v1.0.0-beta.10-capdev.1.0.20260708154710-7c3f06878b06 h1:Xo2IETefY7VDg1fPpEPonWOamKMNn+OzXZPVhc7g6xM= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm v1.0.0-beta.10-capdev.1.0.20260708154710-7c3f06878b06/go.mod h1:npSWJRUuHaxH5oURq3uGTlTXbjzrbR1jiZEFn810APw= github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260612142557-01f4db8d7d47 h1:mJBsvYx40drsXCOqPnDoGCgnpEzboOyO6SRL6u4C1Ts= github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.1-0.20260612142557-01f4db8d7d47/go.mod h1:3Oja7Gviyrb8dtld04krKCsTQ41RP/3hpCmHXjZf31I= github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad h1:lgHxTHuzJIF3Vj6LSMOnjhqKgRqYW+0MV2SExtCYL1Q= @@ -1254,12 +1252,10 @@ github.com/smartcontractkit/libocr v0.0.0-20260508200755-99940c85383c h1:meDKygN github.com/smartcontractkit/libocr v0.0.0-20260508200755-99940c85383c/go.mod h1:PLdNK6GlqfxIWXzziPkU7dCAVlVFeYkyyW7AQY0R+4Q= github.com/smartcontractkit/mcms v0.45.0 h1:6Zx80KKLQOPXLhvrRkJKClANnBJmPa/r69CV5UUq/0I= github.com/smartcontractkit/mcms v0.45.0/go.mod h1:/uOE69QmF7opKlaBNzp8djypmBoYSW0mk4V2iKWP418= -github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9 h1:MOEuXYogv+RStASb8dWsyescu/xkigSi/Sv45NEjV7A= -github.com/smartcontractkit/quarantine v0.0.0-20251203215908-fd0551c6adf9/go.mod h1:iwy4yWFuK+1JeoIRTaSOA9pl+8Kf//26zezxEXrAQEQ= github.com/smartcontractkit/smdkg v0.0.0-20251029093710-c38905e58aeb h1:kLHdQQkijaPGsBbtV2rJgpzVpQ96e7T10pzjNlWfK8U= github.com/smartcontractkit/smdkg v0.0.0-20251029093710-c38905e58aeb/go.mod h1:4s5hj/nlMF9WV+T5Uhy4n9IYpRpzfJzT+vTKkNT7T+Y= -github.com/smartcontractkit/tdh2/go/ocr2/decryptionplugin v0.0.0-20241009055228-33d0c0bf38de h1:n0w0rKF+SVM+S3WNlup6uabXj2zFlFNfrlsKCMMb/co= -github.com/smartcontractkit/tdh2/go/ocr2/decryptionplugin v0.0.0-20241009055228-33d0c0bf38de/go.mod h1:Sl2MF/Fp3fgJIVzhdGhmZZX2BlnM0oUUyBP4s4xYb6o= +github.com/smartcontractkit/tdh2/go/ocr2/decryptionplugin v0.0.0-20260626090144-2343efd61516 h1:kAPHGWh+7nXLvU9aQ+FLmy6k6wNJK/IPzFAl7QpGRaM= +github.com/smartcontractkit/tdh2/go/ocr2/decryptionplugin v0.0.0-20260626090144-2343efd61516/go.mod h1:ceeXxY+DgWnlTDjvaOlYuP+NrcNnvSbrzFwVXYJpkP4= github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c h1:S1AFIjfHT95ev6gqHKBGy1zj3Tz0fIN3XzkaDUn77wY= github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c/go.mod h1:NSc7hgOQbXG3DAwkOdWnZzLTZENXSwDJ7Va1nBp0YU0= github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945 h1:zxcODLrFytOKmAd8ty8S/XK6WcIEJEgRBaL7sY/7l4Y= @@ -1304,9 +1300,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -1333,8 +1329,9 @@ github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EU github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -1369,7 +1366,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= -github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdrpp/goxdr v0.1.1 h1:E1B2c6E8eYhOVyd7yEpOyopzTPirUeF6mVOfXfGyJyc= @@ -1423,12 +1421,12 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= @@ -1445,23 +1443,25 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmc go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= -go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= -go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= -go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= -go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= -go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= +go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -1495,8 +1495,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= -golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -1513,13 +1513,10 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= -golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= @@ -1535,8 +1532,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1566,13 +1563,10 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1589,8 +1583,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1639,31 +1633,23 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -1673,10 +1659,9 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1699,8 +1684,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1721,10 +1706,10 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20210401141331-865547bb08e2/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= -google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= -google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= @@ -1732,8 +1717,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1800,7 +1785,6 @@ k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzk k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= nhooyr.io/websocket v1.8.14 h1:3gKlV2P9bMu1U85zh1T2yLOmseFbRTbnYVOprNSEYKQ= nhooyr.io/websocket v1.8.14/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/install/check_embedded_key.sh b/install/check_embedded_key.sh new file mode 100755 index 00000000..e196969a --- /dev/null +++ b/install/check_embedded_key.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +install_sh="$repo_root/install/install.sh" +public_key="$repo_root/install/public_key.asc" +embedded_key="$(mktemp)" +trap 'rm -f "$embedded_key"' EXIT + +sed -n '/^-----BEGIN PGP PUBLIC KEY BLOCK-----$/,/^-----END PGP PUBLIC KEY BLOCK-----$/p' "$install_sh" >"$embedded_key" + +# Normalize trailing newlines so the check is stable across editors. +normalize_trailing_newline() { + perl -0777 -ne 'print $_ =~ /\n\z/ ? $_ : "$_\n"' "$1" +} + +if ! diff -u <(normalize_trailing_newline "$public_key") <(normalize_trailing_newline "$embedded_key"); then + echo "install/install.sh embedded public key does not match install/public_key.asc" >&2 + exit 1 +fi + +echo "Embedded release public key matches install/public_key.asc" diff --git a/install/install.ps1 b/install/install.ps1 index 11bc97b0..5ca1ba22 100644 --- a/install/install.ps1 +++ b/install/install.ps1 @@ -3,7 +3,7 @@ # It detects the architecture, downloads the correct .exe, # and adds it to the user's PATH. # -# Usage: irm https://cre.chain.link/install.ps1 | iex +# Usage: irm https://app.chain.link/install.ps1 | iex # --- Configuration --- $ErrorActionPreference = "Stop" # Exit script on any error @@ -114,7 +114,87 @@ function Test-BunDependency { } } -# --- Main Installation Logic --- +function Test-ReleaseAuthenticode { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath + ) + + $signature = Get-AuthenticodeSignature -FilePath $FilePath + if ($signature.Status -ne 'Valid') { + Fail "authenticode status: $($signature.Status)" + } + if (-not $signature.SignerCertificate) { + Fail "missing signer certificate" + } + if ($signature.SignerCertificate.Subject -notlike '*SmartContract*') { + Fail "unexpected signer: $($signature.SignerCertificate.Subject)" + } +} + +function Test-UnsafeZipEntry { + param( + [Parameter(Mandatory = $true)] + [string]$EntryName + ) + + if ($EntryName -match '\.\.') { + return $true + } + if ($EntryName -match '^[/\\]') { + return $true + } + return $false +} + +function Extract-ExpectedZipEntry { + param( + [Parameter(Mandatory = $true)] + [string]$ZipPath, + [Parameter(Mandatory = $true)] + [string]$ExpectedEntryName, + [Parameter(Mandatory = $true)] + [string]$DestPath + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) + try { + $matches = @() + foreach ($entry in $zip.Entries) { + if (Test-UnsafeZipEntry -EntryName $entry.FullName) { + throw "Unsafe zip entry: $($entry.FullName)" + } + if ($entry.Name -eq $ExpectedEntryName) { + $matches += $entry + } + } + + if ($matches.Count -ne 1) { + throw "Expected exactly one zip entry named $ExpectedEntryName, found $($matches.Count)." + } + + $entry = $matches[0] + $parent = Split-Path -Parent $DestPath + if (-not (Test-Path -Path $parent)) { + New-Item -ItemType Directory -Path $parent | Out-Null + } + + $entryStream = $entry.Open() + try { + $destStream = [System.IO.File]::Create($DestPath) + try { + $entryStream.CopyTo($destStream) + } finally { + $destStream.Dispose() + } + } finally { + $entryStream.Dispose() + } + } finally { + $zip.Dispose() + } +} try { # 1. Detect Architecture @@ -149,15 +229,14 @@ try { Write-Host "Downloading from $DownloadUrl..." Invoke-WebRequest -Uri $DownloadUrl -OutFile $ZipPath - Write-Host "Extracting $CliName.exe from zip..." - Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::ExtractToDirectory($ZipPath, $TempDir) + $ExpectedExeName = "$($CliName)_$($LatestTag)_windows_$($ArchName).exe" + $ExtractedExePath = Join-Path $TempDir $ExpectedExeName - # Find the extracted exe (assume only one .exe in the zip) - $ExtractedExe = Get-ChildItem -Path $TempDir -Filter "*.exe" | Select-Object -First 1 - if (-not $ExtractedExe) { - throw "No .exe file found in the extracted zip archive." - } + Write-Host "Extracting $ExpectedExeName from zip..." + Extract-ExpectedZipEntry -ZipPath $ZipPath -ExpectedEntryName $ExpectedExeName -DestPath $ExtractedExePath + + Write-Host "Verifying release signature..." + Test-ReleaseAuthenticode -FilePath $ExtractedExePath # Create installation directory if it doesn't exist if (-not (Test-Path -Path $InstallDir)) { @@ -166,7 +245,7 @@ try { # Copy the exe to the install directory and rename $ExePath = Join-Path $InstallDir "$($CliName).exe" - Copy-Item -Path $ExtractedExe.FullName -Destination $ExePath -Force + Copy-Item -Path $ExtractedExePath -Destination $ExePath -Force # Clean up temp directory Remove-Item -Path $TempDir -Recurse -Force diff --git a/install/install.sh b/install/install.sh index e16faea1..ed601a89 100755 --- a/install/install.sh +++ b/install/install.sh @@ -3,7 +3,7 @@ # This is a universal installer script for 'cre'. # It detects the OS and architecture, then downloads the correct binary. # -# Usage: curl -sSL https://cre.chain.link/install.sh | bash +# Usage: curl -sSL https://app.chain.link/install.sh | bash set -e # Exit immediately if a command exits with a non-zero status. @@ -31,6 +31,216 @@ check_command() { command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' is not installed." } +# CRE_RELEASE_PUBLIC_KEY - must stay in sync with install/public_key.asc (prodsec-owned). +write_release_public_key() { + local key_file=$1 + cat >"$key_file" <<'CRE_RELEASE_PUBLIC_KEY' +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGjJhYEDEACcUl1IfB6dKn5VhvbP2LOctUwq/qr81RZmpdADixM9183GJ1JR +2mO0abYAuEdqsA8px7vhkgcL/yigMgGKrWbKzV+Zvcrb9+fLQoLxvG5KVFil7zJn ++9qwmxsvjlUB8VmikxyXBO7E7uxVj2iJWZX22R/k9d4t1lmHG9ewBf+5A7oNrU1P +j8nN0N0UwFgcJp70A5y6j20AJ97SFhUtVGtcaClObtCiPryVUgttdqOniIDBHJQH +OAhQdZmkYYCjnMT/sJyuY7IZzPoJhRioU60pBD/mJPgdwdl0rxCby/lAPffQIh2+ +yakKnHerb6Y1T8m1Xjj+iawRcr6Y9Cr4yhoRawqX1F3DziK2/35RNIY24JyXP8/y +hADOk/gHkigepl17pbZmGIDTUoZEUVb1iRo1/3x9PVWOP1FCbRvJ9B1PrcbwCcZ3 +5bTReX77ZyIrirapAZ2cCqFiTxxaAgoYAZrcOBr7dlJlPJYue84nzLSFnA1llBHc +/KN8IYf5Qud6HEMs9hO4ESCT+CKy2Ng9H4WpiA8ArL0txOIkJ5mx26CavFbyJRnk +IUrQ4HxXgj12yQetBNbI7ysJMuaLneomva3HuQ7zb9/chC05qiaQWXDhQpvKN+0i +AvXiSPU5B29LNzFQO6Srxh0an1Rl+kakNlZMbN4pXvMiVoxR/ecBkPowNwARAQAB +tDNDUkUgPGNyZUBzbWFydGNvbnRyYWN0LmNvbT4gKExpbnV4IEdQRyBTaWduaW5n +IEtleSmJAnMEEAMJAF0FAmjJhYE0HENSRSA8Y3JlQHNtYXJ0Y29udHJhY3QuY29t +PiAoTGludXggR1BHIFNpZ25pbmcgS2V5KRYhBIJEkQLLuJ7ZvuEaSEZFvBQEwvPd +Ah4DAhsDBBUICQoACgkQRkW8FATC890a8Q/+LJXi64znLkZei+VK7wQzlRW6Mo13 +U2BzNd5ZWMqWrU5LomSU4uDHQkGBEx9CCCfRcIv6bdbM9Iga6yzFSIv8HZxgb6tL +bP2/Ly9+cgqUGTQ5ChBrs68DiTxAS4skSSP7ap5pL/TLp+Qc8AUN2XhlRJz0HLTO +bZoz5fBTKBOBAKNz2zu8uWYdijx9cX1YPr2HsuT/HF9dcmSDRXY0nSkvebWcSN8s +Tu/g22eBrQkiNRjqsRuxdxG1SQHL+Qq5DK6xRc7KUVaZCjBTnGLXaMPhFxwZkvW7 +PTa21XRW/f2bTDR/vxpjwN9n5yFOxnm4pWiJEW1jodXzIhMNDqsJTGsk+N+4kT8k +0rAoHd0D1pmo8jQXLG2FldP359JDfZMR10S1Lv7uBhPsgj9vUA4uWsy7Prf5H9zo +JTQ3B/xVi0LYYKveu/Nm3VvXJY53vfAWmIn6s0iLrTrlSrZuglfK70HnMJv5a6jc +BcyE563wmJKVjLK8ZqggPYdOaeVZfy0k4wmyupVjNU0O5GxMUg7dANmtu8bDHUBU +MBo06MuHpthkdM1DHxnpBLw0YsWzumpEpVZatASWfZ5o1pxm/PB4KR6rsY4bdnoD +wUlRxURvO/I2jQJPacYrw24pb7ufRs9MXqQEUEbSpXRBs5CbBADw2qRcr7vrZnze +a8cULyg4Y65LBeU= +=cKUx +-----END PGP PUBLIC KEY BLOCK----- +CRE_RELEASE_PUBLIC_KEY +} + +is_unsafe_archive_entry() { + local entry=$1 + case "$entry" in + ../* | */../* | */.. | .. | /* | \\*) + return 0 + ;; + esac + return 1 +} + +safe_extract_archive() { + local archive_path=$1 + local dest_dir=$2 + local expected_member=$3 + + if echo "$archive_path" | grep -qE '\.tar\.gz$'; then + check_command "tar" + local members + members=$(tar -tzf "$archive_path") || fail "Failed to list tar archive members." + + local member_count=0 + local matched_member="" + while IFS= read -r entry; do + [ -n "$entry" ] || continue + if is_unsafe_archive_entry "$entry"; then + fail "Unsafe tar entry: $entry" + fi + member_count=$((member_count + 1)) + if [ "$entry" = "$expected_member" ]; then + matched_member=$entry + elif [ "${entry%/}" = "$expected_member" ]; then + matched_member="${entry%/}" + fi + done <<<"$members" + + if [ "$member_count" -ne 1 ]; then + fail "Expected exactly one archive member, found $member_count." + fi + if [ -z "$matched_member" ]; then + fail "Expected archive member $expected_member not found." + fi + + tar -xzf "$archive_path" -C "$dest_dir" "$matched_member" || + fail "Failed to extract $matched_member from archive." + return + fi + + if echo "$archive_path" | grep -qE '\.zip$'; then + check_command "unzip" + local members + members=$(unzip -Z1 "$archive_path") || fail "Failed to list zip archive members." + + local member_count=0 + local matched_member="" + while IFS= read -r entry; do + [ -n "$entry" ] || continue + if is_unsafe_archive_entry "$entry"; then + fail "Unsafe zip entry: $entry" + fi + member_count=$((member_count + 1)) + if [ "$entry" = "$expected_member" ]; then + matched_member=$entry + elif [ "${entry%/}" = "$expected_member" ]; then + matched_member="${entry%/}" + fi + done <<<"$members" + + if [ "$member_count" -ne 1 ]; then + fail "Expected exactly one archive member, found $member_count." + fi + if [ -z "$matched_member" ]; then + fail "Expected archive member $expected_member not found." + fi + + unzip -oq "$archive_path" -d "$dest_dir" "$matched_member" || + fail "Failed to extract $matched_member from archive." + return + fi + + fail "Unknown archive format: $archive_path" +} + +verify_linux_gpg_signature() { + local bin_path=$1 + local sig_path=$2 + local key_file=$3 + + check_command "gpg" + + gpg --batch --import "$key_file" >/dev/null 2>&1 || + fail "Failed to import release public key." + + local gpg_out + gpg_out=$(gpg --batch --status-fd=1 --verify "$sig_path" "$bin_path" 2>&1) || + fail "GPG signature verification failed." + + echo "$gpg_out" | grep -q '\[GNUPG:\] VALIDSIG' || + fail "GPG signature verification failed: no valid signature." + echo "$gpg_out" | grep -q 'cre@smartcontract.com' || + fail "GPG signature verification failed: unexpected signer." +} + +verify_darwin_codesign() { + local bin_path=$1 + + codesign --verify --strict --identifier com.smartcontract.cre.cli "$bin_path" || + fail "codesign verification failed." +} + +verify_release_binary() { + local bin_path=$1 + local sig_path=$2 + local key_file=$3 + + case "$PLATFORM" in + linux) + verify_linux_gpg_signature "$bin_path" "$sig_path" "$key_file" + ;; + darwin) + verify_darwin_codesign "$bin_path" + ;; + *) + fail "Unsupported platform for release verification: $PLATFORM" + ;; + esac +} + +LINUX_LDD235_SUFFIX="_ldd2-35" +LINUX_GLIBC_THRESHOLD="2.36" + +parse_glibc_version_from_ldd_output() { + local output=$1 + local first_line version + + first_line=$(printf '%s' "$output" | head -n1) + version=$(printf '%s' "$first_line" | grep -oE '[0-9]+\.[0-9]+' | tail -n1) + if [ -z "$version" ]; then + return 1 + fi + printf '%s' "$version" +} + +version_lt() { + local left=$1 + local right=$2 + [ "$(printf '%s\n' "$left" "$right" | sort -V | head -n1)" = "$left" ] && [ "$left" != "$right" ] +} + +linux_asset_suffix() { + local ldd_output version + + if [ "$PLATFORM" != "linux" ]; then + printf '' + return + fi + + ldd_output=$(ldd --version 2>/dev/null | head -n1) || { + printf '' + return + } + + version=$(parse_glibc_version_from_ldd_output "$ldd_output") || { + printf '' + return + } + + if version_lt "$version" "$LINUX_GLIBC_THRESHOLD"; then + printf '%s' "$LINUX_LDD235_SUFFIX" + else + printf '' + fi +} + tildify() { if [[ $1 = $HOME/* ]]; then local replacement=\~/ @@ -157,7 +367,15 @@ else fi # 4. Construct Download URL and Download asset -ASSET="${cli_name}_${PLATFORM}_${ARCH_NAME}" +LINUX_SUFFIX="" +if [ "$PLATFORM" = "linux" ]; then + LINUX_SUFFIX=$(linux_asset_suffix) + if [ -n "$LINUX_SUFFIX" ]; then + echo "Using glibc-compatible build for older Linux (ldd2-35)..." + fi +fi + +ASSET="${cli_name}_${PLATFORM}_${ARCH_NAME}${LINUX_SUFFIX}" # Determine the file extension based on OS if [ "$PLATFORM" = "linux" ]; then ASSET="${ASSET}.tar.gz" @@ -167,24 +385,33 @@ fi DOWNLOAD_URL="https://github.com/$github_repo/releases/download/$LATEST_TAG/$ASSET" TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT ARCHIVE_PATH="$TMP_DIR/$ASSET" curl --fail --location --progress-bar "$DOWNLOAD_URL" --output "$ARCHIVE_PATH" || fail "Failed to download asset from $DOWNLOAD_URL" -# 5. Extract archive and locate the binary -if echo "$ASSET" | grep -qE '\.tar\.gz$'; then - check_command "tar" - tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR" -elif echo "$ASSET" | grep -qE '\.zip$'; then - check_command "unzip" - unzip -oq "$ARCHIVE_PATH" -d "$TMP_DIR" -else - fail "Unknown archive format: $ASSET" +EXPECTED_BIN_NAME="${cli_name}_${LATEST_TAG}_${PLATFORM}_${ARCH_NAME}" +TMP_CRE_BIN="$TMP_DIR/$EXPECTED_BIN_NAME" +PUBLIC_KEY_FILE="$TMP_DIR/public_key.asc" +SIG_PATH="" + +if [ "$PLATFORM" = "linux" ]; then + check_command "gpg" + SIG_ASSET="${cli_name}_${PLATFORM}_${ARCH_NAME}${LINUX_SUFFIX}.sig" + SIG_URL="https://github.com/$github_repo/releases/download/$LATEST_TAG/$SIG_ASSET" + SIG_PATH="$TMP_DIR/$SIG_ASSET" + curl --fail --location --progress-bar "$SIG_URL" --output "$SIG_PATH" || + fail "Failed to download signature from $SIG_URL" + write_release_public_key "$PUBLIC_KEY_FILE" fi -TMP_CRE_BIN="$TMP_DIR/${cli_name}_${LATEST_TAG}_${PLATFORM}_${ARCH_NAME}" +# 5. Extract archive and verify release authenticity before install +safe_extract_archive "$ARCHIVE_PATH" "$TMP_DIR" "$EXPECTED_BIN_NAME" [ -f "$TMP_CRE_BIN" ] || fail "Binary $TMP_CRE_BIN not found after extraction." + +verify_release_binary "$TMP_CRE_BIN" "$SIG_PATH" "$PUBLIC_KEY_FILE" + chmod +x "$TMP_CRE_BIN" # 6. Install the Binary (moving into place) @@ -199,9 +426,6 @@ fi # 7. Check that the binary runs "$cre_bin" version || fail "$cli_name installation failed." -# Cleanup -rm -rf "$TMP_DIR" - # 8. Post-install dependency checks (Go & Bun) echo echo "Performing environment checks for CRE workflows..." diff --git a/install/keys.go b/install/keys.go new file mode 100644 index 00000000..c31efa71 --- /dev/null +++ b/install/keys.go @@ -0,0 +1,8 @@ +package install + +import _ "embed" + +// ReleasePublicKey is the Linux GPG public key used to verify cre release binaries. +// +//go:embed public_key.asc +var ReleasePublicKey []byte diff --git a/install/linux_asset_suffix_test.sh b/install/linux_asset_suffix_test.sh new file mode 100755 index 00000000..071c5862 --- /dev/null +++ b/install/linux_asset_suffix_test.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +install_sh="$repo_root/install/install.sh" +failures=0 + +pass() { echo "PASS: $1"; } +fail_test() { echo "FAIL: $1"; failures=$((failures + 1)); } + +# shellcheck disable=SC1090 +source <(sed -n '198,242p' "$install_sh") + +assert_parse_version() { + local name=$1 + local output=$2 + local want=$3 + + if got=$(parse_glibc_version_from_ldd_output "$output"); then + if [ "$got" = "$want" ]; then + pass "$name" + else + fail_test "$name (got $got, want $want)" + fi + else + fail_test "$name (parse failed)" + fi +} + +assert_suffix() { + local name=$1 + local output=$2 + local want=$3 + local version suffix + + PLATFORM=linux + if ! version=$(parse_glibc_version_from_ldd_output "$output"); then + suffix="" + elif version_lt "$version" "$LINUX_GLIBC_THRESHOLD"; then + suffix="$LINUX_LDD235_SUFFIX" + else + suffix="" + fi + + if [ "$suffix" = "$want" ]; then + pass "$name" + else + fail_test "$name (got '$suffix', want '$want')" + fi +} + +assert_parse_version "ubuntu 22.04" \ + $'ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35\nCopyright (C) 2022 Free Software Foundation, Inc.\n' \ + "2.35" + +assert_parse_version "ubuntu 24.04" \ + $'ldd (Ubuntu GLIBC 2.39-0ubuntu8.4) 2.39\n' \ + "2.39" + +assert_parse_version "rhel style" \ + $'ldd (GNU libc) 2.34\n' \ + "2.34" + +if parse_glibc_version_from_ldd_output "" >/dev/null 2>&1; then + fail_test "empty output rejects parse" +else + pass "empty output rejects parse" +fi + +assert_suffix "ubuntu 22.04 uses ldd2-35" \ + $'ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35\n' \ + "_ldd2-35" + +assert_suffix "ubuntu 24.04 uses default asset" \ + $'ldd (Ubuntu GLIBC 2.39-0ubuntu8.4) 2.39\n' \ + "" + +assert_suffix "rhel 2.34 uses ldd2-35" \ + $'ldd (GNU libc) 2.34\n' \ + "_ldd2-35" + +assert_suffix "empty output fails open" "" "" + +if [ "$failures" -ne 0 ]; then + echo "$failures linux asset suffix test(s) failed" >&2 + exit 1 +fi + +echo "All linux asset suffix tests passed." diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go index 60c62bdf..2bbc62ba 100644 --- a/internal/accessrequest/accessrequest.go +++ b/internal/accessrequest/accessrequest.go @@ -66,7 +66,7 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { huh.NewGroup( huh.NewText(). Title("Briefly describe your use case"). - Description("What are you building with CRE?"). + Description("If possible, include your repository to help us validate your request."). CharLimit(1500). Value(&useCase). Validate(func(s string) error { @@ -95,7 +95,8 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { ui.Line() ui.Success("Access request submitted successfully!") ui.Line() - ui.Print("Our team will review your request and get back to you via email shortly.") + ui.Print("Thanks for applying.") + ui.Print("We're carefully reviewing each request to ensure we can provide the best experience. If your request is approved, we'll contact you with next steps.") ui.Line() return nil diff --git a/internal/client/workflowdataclient/execution.go b/internal/client/workflowdataclient/execution.go new file mode 100644 index 00000000..e4657ba0 --- /dev/null +++ b/internal/client/workflowdataclient/execution.go @@ -0,0 +1,447 @@ +package workflowdataclient + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/machinebox/graphql" +) + +// ExecutionStatus mirrors the WorkflowExecutionStatus enum from the platform schema. +type ExecutionStatus string + +const ( + ExecutionStatusUnknown ExecutionStatus = "UNKNOWN" + ExecutionStatusUnspecified ExecutionStatus = "UNSPECIFIED" + ExecutionStatusTriggered ExecutionStatus = "TRIGGERED" + ExecutionStatusInProgress ExecutionStatus = "IN_PROGRESS" + ExecutionStatusSuccess ExecutionStatus = "SUCCESS" + ExecutionStatusFailure ExecutionStatus = "FAILURE" +) + +// ValidExecutionStatuses is the full set of values accepted by the platform. +var ValidExecutionStatuses = []ExecutionStatus{ + ExecutionStatusTriggered, + ExecutionStatusInProgress, + ExecutionStatusSuccess, + ExecutionStatusFailure, +} + +// ExecutionError is a top-level error on a workflow execution. +type ExecutionError struct { + Error string + Count int +} + +// Execution is a single workflow execution record. +type Execution struct { + UUID string + ID string // on-chain execution ID shown in the Explorer UI + WorkflowUUID string + WorkflowID string // on-chain workflow hash (workflowId scalar) + WorkflowName string + Status ExecutionStatus + StartedAt time.Time + FinishedAt *time.Time + CreditUsed *string // CreditAmount scalar serialised as a string + Errors []ExecutionError +} + +// CapabilityExecutionError is an error attached to a capability event. +type CapabilityExecutionError struct { + Error string + Count int +} + +// ExecutionEvent is one node/capability event within an execution. +type ExecutionEvent struct { + CapabilityID string + Status string + StartedAt time.Time + FinishedAt *time.Time + Errors []CapabilityExecutionError + Method *string +} + +// ExecutionLog is a single log line emitted during an execution. +type ExecutionLog struct { + NodeID string + Message string + Timestamp time.Time +} + +// ListExecutionsInput maps to WorkflowExecutionsInput on the platform. +type ListExecutionsInput struct { + WorkflowUUID *string + Statuses []ExecutionStatus + From *time.Time + To *time.Time + // Search is passed to the platform search filter (e.g. on-chain execution id hex). + Search *string + // Limit is the maximum number of results to return (capped at 100 by the API). + Limit int +} + +// ListEventsInput maps to WorkflowExecutionEventsInput on the platform. +type ListEventsInput struct { + ExecutionUUID string + CapabilityID *string + Status *string +} + +// ---- GraphQL query strings ---- + +const listExecutionsQuery = ` +query ListExecutions($input: WorkflowExecutionsInput!) { + workflowExecutions(input: $input) { + data { + uuid + id + workflowUUID + workflowId + workflowName + status + startedAt + finishedAt + creditUsed + errors { + error + count + } + } + count + } +} +` + +const getExecutionQuery = ` +query GetExecution($input: WorkflowExecutionInput!) { + workflowExecution(input: $input) { + data { + uuid + id + workflowUUID + workflowId + workflowName + status + startedAt + finishedAt + creditUsed + errors { + error + count + } + } + } +} +` + +const listExecutionEventsQuery = ` +query ListExecutionEvents($input: WorkflowExecutionEventsInput!) { + workflowExecutionEvents(input: $input) { + data { + capabilityID + status + startedAt + finishedAt + method + errors { + error + count + } + } + } +} +` + +const listExecutionLogsQuery = ` +query ListExecutionLogs($input: WorkflowExecutionLogsInput!) { + workflowExecutionLogs(input: $input) { + data { + nodeID + message + timestamp + } + } +} +` + +// ---- GQL envelope types ---- + +type gqlExecutionError struct { + Error string `json:"error"` + Count int `json:"count"` +} + +type gqlExecution struct { + UUID string `json:"uuid"` + ID string `json:"id"` + WorkflowUUID string `json:"workflowUUID"` + WorkflowID string `json:"workflowId"` + WorkflowName string `json:"workflowName"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` + CreditUsed *string `json:"creditUsed"` + Errors []gqlExecutionError `json:"errors"` +} + +type listExecutionsEnvelope struct { + WorkflowExecutions struct { + Data []gqlExecution `json:"data"` + Count int `json:"count"` + } `json:"workflowExecutions"` +} + +type getExecutionEnvelope struct { + WorkflowExecution struct { + Data *gqlExecution `json:"data"` + } `json:"workflowExecution"` +} + +type gqlCapabilityError struct { + Error string `json:"error"` + Count int `json:"count"` +} + +type gqlExecutionEvent struct { + CapabilityID string `json:"capabilityID"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` + Method *string `json:"method"` + Errors []gqlCapabilityError `json:"errors"` +} + +type listEventsEnvelope struct { + WorkflowExecutionEvents struct { + Data []gqlExecutionEvent `json:"data"` + } `json:"workflowExecutionEvents"` +} + +type gqlExecutionLog struct { + NodeID string `json:"nodeID"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +type listLogsEnvelope struct { + WorkflowExecutionLogs struct { + Data []gqlExecutionLog `json:"data"` + } `json:"workflowExecutionLogs"` +} + +// ---- Client methods ---- + +// ListExecutions fetches workflow executions matching the given filters. +// At most one page of results is returned; Limit controls page size (max 100). +func (c *Client) ListExecutions(parent context.Context, in ListExecutionsInput) ([]Execution, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + limit := in.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + input := map[string]any{ + "page": map[string]any{ + "number": 0, + "size": limit, + }, + } + if in.WorkflowUUID != nil { + input["workflowUuid"] = *in.WorkflowUUID + } + if len(in.Statuses) > 0 { + ss := make([]string, len(in.Statuses)) + for i, s := range in.Statuses { + ss[i] = string(s) + } + input["status"] = ss + } + if in.From != nil { + input["from"] = in.From.UTC().Format(time.RFC3339) + } + if in.To != nil { + input["to"] = in.To.UTC().Format(time.RFC3339) + } + if in.Search != nil && *in.Search != "" { + input["search"] = *in.Search + } + + req := graphql.NewRequest(listExecutionsQuery) + req.Var("input", input) + + var env listExecutionsEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("list executions: %w", err) + } + + return toExecutions(env.WorkflowExecutions.Data), nil +} + +// FindExecutionByOnChainID resolves the platform UUID for an execution given its +// on-chain hex ID (the identifier shown in the Explorer UI). It uses the platform +// workflowExecutions search filter, then verifies an exact id match on the result. +func (c *Client) FindExecutionByOnChainID(parent context.Context, onChainID string) (*Execution, error) { + search := onChainID + executions, err := c.ListExecutions(parent, ListExecutionsInput{Search: &search, Limit: 10}) + if err != nil { + return nil, fmt.Errorf("searching for execution %q: %w", onChainID, err) + } + for _, e := range executions { + if strings.EqualFold(e.ID, onChainID) { + full, err := c.GetExecution(parent, e.UUID) + if err != nil { + return nil, err + } + return full, nil + } + } + return nil, fmt.Errorf("execution with ID %q not found", onChainID) +} + +// CountExecutions returns the total number of executions matching the given filters. +// It fetches only a single-item page — only the count field is used. +func (c *Client) CountExecutions(parent context.Context, workflowUUID string, statuses []ExecutionStatus) (int, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + input := map[string]any{ + "workflowUuid": workflowUUID, + "page": map[string]any{"number": 0, "size": 1}, + } + if len(statuses) > 0 { + ss := make([]string, len(statuses)) + for i, s := range statuses { + ss[i] = string(s) + } + input["status"] = ss + } + + req := graphql.NewRequest(listExecutionsQuery) + req.Var("input", input) + + var env listExecutionsEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return 0, fmt.Errorf("count executions: %w", err) + } + return env.WorkflowExecutions.Count, nil +} + +// GetExecution fetches a single execution by its UUID. +func (c *Client) GetExecution(parent context.Context, uuid string) (*Execution, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + req := graphql.NewRequest(getExecutionQuery) + req.Var("input", map[string]any{"uuid": uuid}) + + var env getExecutionEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("get execution: %w", err) + } + + if env.WorkflowExecution.Data == nil { + return nil, fmt.Errorf("execution %q not found", uuid) + } + + e := toExecution(*env.WorkflowExecution.Data) + return &e, nil +} + +// ListExecutionEvents fetches all node/capability events for an execution. +func (c *Client) ListExecutionEvents(parent context.Context, in ListEventsInput) ([]ExecutionEvent, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + input := map[string]any{ + "workflowExecutionUUID": in.ExecutionUUID, + } + if in.CapabilityID != nil { + input["capabilityID"] = *in.CapabilityID + } + if in.Status != nil { + input["status"] = *in.Status + } + + req := graphql.NewRequest(listExecutionEventsQuery) + req.Var("input", input) + + var env listEventsEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("list execution events: %w", err) + } + + events := make([]ExecutionEvent, 0, len(env.WorkflowExecutionEvents.Data)) + for _, g := range env.WorkflowExecutionEvents.Data { + errs := make([]CapabilityExecutionError, 0, len(g.Errors)) + for _, e := range g.Errors { + errs = append(errs, CapabilityExecutionError(e)) + } + events = append(events, ExecutionEvent{ + CapabilityID: g.CapabilityID, + Status: g.Status, + StartedAt: g.StartedAt, + FinishedAt: g.FinishedAt, + Method: g.Method, + Errors: errs, + }) + } + return events, nil +} + +// ListExecutionLogs fetches all log lines for an execution. +func (c *Client) ListExecutionLogs(parent context.Context, executionUUID string) ([]ExecutionLog, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + req := graphql.NewRequest(listExecutionLogsQuery) + req.Var("input", map[string]any{"workflowExecutionUUID": executionUUID}) + + var env listLogsEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("list execution logs: %w", err) + } + + logs := make([]ExecutionLog, 0, len(env.WorkflowExecutionLogs.Data)) + for _, g := range env.WorkflowExecutionLogs.Data { + logs = append(logs, ExecutionLog(g)) + } + return logs, nil +} + +// ---- helpers ---- + +func toExecution(g gqlExecution) Execution { + errs := make([]ExecutionError, 0, len(g.Errors)) + for _, e := range g.Errors { + errs = append(errs, ExecutionError(e)) + } + return Execution{ + UUID: g.UUID, + ID: g.ID, + WorkflowUUID: g.WorkflowUUID, + WorkflowID: g.WorkflowID, + WorkflowName: g.WorkflowName, + Status: ExecutionStatus(g.Status), + StartedAt: g.StartedAt, + FinishedAt: g.FinishedAt, + CreditUsed: g.CreditUsed, + Errors: errs, + } +} + +func toExecutions(gs []gqlExecution) []Execution { + out := make([]Execution, 0, len(gs)) + for _, g := range gs { + out = append(out, toExecution(g)) + } + return out +} diff --git a/internal/client/workflowdataclient/execution_test.go b/internal/client/workflowdataclient/execution_test.go new file mode 100644 index 00000000..fef1932a --- /dev/null +++ b/internal/client/workflowdataclient/execution_test.go @@ -0,0 +1,259 @@ +package workflowdataclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func gqlData(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": payload}) +} + +func TestListExecutions_DefaultLimitAndMapping(t *testing.T) { + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + finished := time.Date(2026, 5, 29, 14, 0, 17, 0, time.UTC) + credit := "0.05" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + vars, _ := body["variables"].(map[string]any) + input, _ := vars["input"].(map[string]any) + page, _ := input["page"].(map[string]any) + assert.Equal(t, float64(20), page["size"]) + + gqlData(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "exec-uuid-1", + "id": "0xabc", + "workflowUUID": "wf-uuid-1", + "workflowId": "wf-onchain", + "workflowName": "my-workflow", + "status": "FAILURE", + "startedAt": started.Format(time.RFC3339), + "finishedAt": finished.Format(time.RFC3339), + "creditUsed": credit, + "errors": []any{ + map[string]any{"error": "boom", "count": 1}, + }, + }, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.ListExecutions(context.Background(), ListExecutionsInput{}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "exec-uuid-1", got[0].UUID) + assert.Equal(t, ExecutionStatusFailure, got[0].Status) + require.NotNil(t, got[0].CreditUsed) + assert.Equal(t, credit, *got[0].CreditUsed) + require.Len(t, got[0].Errors, 1) + assert.Equal(t, "boom", got[0].Errors[0].Error) +} + +func TestListExecutions_PassesFilters(t *testing.T) { + wfUUID := "wf-uuid-1" + from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) + search := "0xdeadbeef" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + vars, _ := body["variables"].(map[string]any) + input, _ := vars["input"].(map[string]any) + assert.Equal(t, wfUUID, input["workflowUuid"]) + assert.Equal(t, search, input["search"]) + assert.Equal(t, from.UTC().Format(time.RFC3339), input["from"]) + assert.Equal(t, to.UTC().Format(time.RFC3339), input["to"]) + statuses, _ := input["status"].([]any) + require.Len(t, statuses, 1) + assert.Equal(t, "SUCCESS", statuses[0]) + + gqlData(w, map[string]any{ + "workflowExecutions": map[string]any{"count": 0, "data": []any{}}, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + _, err := client.ListExecutions(context.Background(), ListExecutionsInput{ + WorkflowUUID: &wfUUID, + Statuses: []ExecutionStatus{ExecutionStatusSuccess}, + From: &from, + To: &to, + Search: &search, + Limit: 50, + }) + require.NoError(t, err) +} + +func TestGetExecution_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlData(w, map[string]any{ + "workflowExecution": map[string]any{"data": nil}, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + _, err := client.GetExecution(context.Background(), "missing-uuid") + require.Error(t, err) + assert.Contains(t, err.Error(), `execution "missing-uuid" not found`) +} + +func TestFindExecutionByOnChainID_ResolvesViaSearch(t *testing.T) { + onChainID := "0xabc123" + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + query, _ := body["query"].(string) + + if strings.Contains(query, "ListExecutions") { + gqlData(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "exec-uuid-1", "id": onChainID, + "workflowUUID": "wf-1", "workflowName": "wf", + "status": "SUCCESS", "startedAt": started.Format(time.RFC3339), + "errors": []any{}, + }, + }, + }, + }) + return + } + + gqlData(w, map[string]any{ + "workflowExecution": map[string]any{ + "data": map[string]any{ + "uuid": "exec-uuid-1", "id": onChainID, + "workflowUUID": "wf-1", "workflowName": "wf", + "status": "SUCCESS", "startedAt": started.Format(time.RFC3339), + "errors": []any{}, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.FindExecutionByOnChainID(context.Background(), onChainID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "exec-uuid-1", got.UUID) + assert.Equal(t, onChainID, got.ID) +} + +func TestCountExecutions_ReturnsTotal(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlData(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 42, + "data": []any{}, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + count, err := client.CountExecutions(context.Background(), "wf-uuid-1", []ExecutionStatus{ExecutionStatusFailure}) + require.NoError(t, err) + assert.Equal(t, 42, count) +} + +func TestListExecutionEvents_PassesFilters(t *testing.T) { + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + capID := "fetch-price" + status := "FAILURE" + method := "invoke" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + vars, _ := body["variables"].(map[string]any) + input, _ := vars["input"].(map[string]any) + assert.Equal(t, "exec-uuid-1", input["workflowExecutionUUID"]) + assert.Equal(t, capID, input["capabilityID"]) + assert.Equal(t, status, input["status"]) + + gqlData(w, map[string]any{ + "workflowExecutionEvents": map[string]any{ + "data": []any{ + map[string]any{ + "capabilityID": capID, + "status": status, + "startedAt": started.Format(time.RFC3339), + "method": method, + "errors": []any{}, + }, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.ListExecutionEvents(context.Background(), ListEventsInput{ + ExecutionUUID: "exec-uuid-1", + CapabilityID: &capID, + Status: &status, + }) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, capID, got[0].CapabilityID) + require.NotNil(t, got[0].Method) + assert.Equal(t, method, *got[0].Method) +} + +func TestListExecutionLogs_MapsRows(t *testing.T) { + ts := time.Date(2026, 5, 29, 14, 0, 10, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + vars, _ := body["variables"].(map[string]any) + input, _ := vars["input"].(map[string]any) + assert.Equal(t, "exec-uuid-1", input["workflowExecutionUUID"]) + + gqlData(w, map[string]any{ + "workflowExecutionLogs": map[string]any{ + "data": []any{ + map[string]any{ + "nodeID": "ProcessData", + "message": "done", + "timestamp": ts.Format(time.RFC3339), + }, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.ListExecutionLogs(context.Background(), "exec-uuid-1") + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "ProcessData", got[0].NodeID) + assert.Equal(t, "done", got[0].Message) +} diff --git a/internal/client/workflowdataclient/workflow_status.go b/internal/client/workflowdataclient/workflow_status.go new file mode 100644 index 00000000..5eb49698 --- /dev/null +++ b/internal/client/workflowdataclient/workflow_status.go @@ -0,0 +1,234 @@ +package workflowdataclient + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/machinebox/graphql" +) + +// WorkflowSummary is an extended workflow record including execution health fields. +type WorkflowSummary struct { + UUID string + Name string + WorkflowID string + OwnerAddress string + Status string + WorkflowSource string + RegisteredAt time.Time + ExecutedAt *time.Time + ExecutionCount int + SuccessCount int + FailureCount int +} + +// WorkflowDeploymentRecord is a single deployment entry. +type WorkflowDeploymentRecord struct { + UUID string + WorkflowID string + Status string + DeployedAt time.Time + TxHash *string + BinaryURL *string + ConfigURL *string + ErrorMessage *string +} + +// ---- queries ---- + +const getWorkflowQuery = ` +query GetWorkflow($input: WorkflowInput!) { + workflow(input: $input) { + data { + uuid + name + workflowId + ownerAddress + status + workflowSource + registeredAt + executedAt + executionCount + executionCountByStatus { + success + failure + } + } + } +} +` + +// getWorkflowQueryWithoutExecutionBreakdown omits executionCountByStatus for +// platform versions where GetWorkflow does not populate that non-null field. +const getWorkflowQueryWithoutExecutionBreakdown = ` +query GetWorkflow($input: WorkflowInput!) { + workflow(input: $input) { + data { + uuid + name + workflowId + ownerAddress + status + workflowSource + registeredAt + executedAt + executionCount + } + } +} +` + +const getLatestDeploymentQuery = ` +query GetLatestDeployment($input: WorkflowDeploymentsInput!) { + workflowDeployments(input: $input) { + data { + uuid + workflowID + status + deployedAt + txHash + binaryURL + configURL + errorMessage + } + } +} +` + +// ---- envelopes ---- + +type gqlWorkflowSummary struct { + UUID string `json:"uuid"` + Name string `json:"name"` + WorkflowID string `json:"workflowId"` + OwnerAddress string `json:"ownerAddress"` + Status string `json:"status"` + WorkflowSource string `json:"workflowSource"` + RegisteredAt time.Time `json:"registeredAt"` + ExecutedAt *time.Time `json:"executedAt"` + ExecutionCount int `json:"executionCount"` + ExecutionCountByStatus struct { + Success int `json:"success"` + Failure int `json:"failure"` + } `json:"executionCountByStatus"` +} + +type getWorkflowEnvelope struct { + Workflow struct { + Data *gqlWorkflowSummary `json:"data"` + } `json:"workflow"` +} + +type gqlDeploymentRecord struct { + UUID string `json:"uuid"` + WorkflowID string `json:"workflowID"` + Status string `json:"status"` + DeployedAt time.Time `json:"deployedAt"` + TxHash *string `json:"txHash"` + BinaryURL *string `json:"binaryURL"` + ConfigURL *string `json:"configURL"` + ErrorMessage *string `json:"errorMessage"` +} + +type getLatestDeploymentEnvelope struct { + WorkflowDeployments struct { + Data []gqlDeploymentRecord `json:"data"` + } `json:"workflowDeployments"` +} + +// ---- methods ---- + +// GetWorkflowSummary fetches extended workflow details including execution health. +func (c *Client) GetWorkflowSummary(parent context.Context, uuid string, from time.Time) (*WorkflowSummary, error) { + summary, err := c.fetchWorkflowSummaryWithQuery(parent, uuid, from, getWorkflowQuery) + if err != nil && isNonNullGraphQLError(err) { + c.log.Debug().Msg("GetWorkflow executionCountByStatus unavailable; retrying without breakdown fields") + return c.fetchWorkflowSummaryWithQuery(parent, uuid, from, getWorkflowQueryWithoutExecutionBreakdown) + } + return summary, err +} + +func (c *Client) fetchWorkflowSummaryWithQuery(parent context.Context, uuid string, from time.Time, query string) (*WorkflowSummary, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + req := graphql.NewRequest(query) + req.Var("input", map[string]any{ + "uuid": uuid, + "from": from.UTC().Format(time.RFC3339), + }) + + var env getWorkflowEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("get workflow summary: %w", err) + } + + if env.Workflow.Data == nil { + return nil, fmt.Errorf("workflow %q not found", uuid) + } + + g := *env.Workflow.Data + return &WorkflowSummary{ + UUID: g.UUID, + Name: g.Name, + WorkflowID: g.WorkflowID, + OwnerAddress: g.OwnerAddress, + Status: g.Status, + WorkflowSource: g.WorkflowSource, + RegisteredAt: g.RegisteredAt, + ExecutedAt: g.ExecutedAt, + ExecutionCount: g.ExecutionCount, + SuccessCount: g.ExecutionCountByStatus.Success, + FailureCount: g.ExecutionCountByStatus.Failure, + }, nil +} + +func isNonNullGraphQLError(err error) bool { + return err != nil && strings.Contains(err.Error(), "the requested element is null") +} + +// GetLatestDeployment fetches the most recent deployment record for a workflow. +// from/to mirror what the Explorer UI passes — the backend requires them even though +// the schema marks them optional. +func (c *Client) GetLatestDeployment(parent context.Context, workflowUUID string, from, to time.Time) (*WorkflowDeploymentRecord, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + req := graphql.NewRequest(getLatestDeploymentQuery) + req.Var("input", map[string]any{ + "workflowUUID": workflowUUID, + "from": from.UTC().Format(time.RFC3339), + "to": to.UTC().Format(time.RFC3339), + "orderBy": map[string]any{ + "field": "DEPLOYED_AT", + "order": "DESC", + }, + "page": map[string]any{ + "number": 0, + "size": 1, + }, + }) + + var env getLatestDeploymentEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("get latest deployment: %w", err) + } + + if len(env.WorkflowDeployments.Data) == 0 { + return nil, nil //nolint:nilnil // no deployment record is a valid state + } + + g := env.WorkflowDeployments.Data[0] + return &WorkflowDeploymentRecord{ + UUID: g.UUID, + WorkflowID: g.WorkflowID, + Status: g.Status, + DeployedAt: g.DeployedAt, + TxHash: g.TxHash, + BinaryURL: g.BinaryURL, + ConfigURL: g.ConfigURL, + ErrorMessage: g.ErrorMessage, + }, nil +} diff --git a/internal/client/workflowdataclient/workflow_status_test.go b/internal/client/workflowdataclient/workflow_status_test.go new file mode 100644 index 00000000..d3eb867a --- /dev/null +++ b/internal/client/workflowdataclient/workflow_status_test.go @@ -0,0 +1,171 @@ +package workflowdataclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetWorkflowSummary_MapsFields(t *testing.T) { + registered := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) + executed := time.Date(2026, 1, 15, 8, 30, 0, 0, time.UTC) + from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + vars, _ := body["variables"].(map[string]any) + input, _ := vars["input"].(map[string]any) + assert.Equal(t, "wf-uuid-1", input["uuid"]) + assert.Equal(t, from.UTC().Format(time.RFC3339), input["from"]) + + gqlData(w, map[string]any{ + "workflow": map[string]any{ + "data": map[string]any{ + "uuid": "wf-uuid-1", + "name": "my-workflow", + "workflowId": "abc123onchain", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + "registeredAt": registered.Format(time.RFC3339), + "executedAt": executed.Format(time.RFC3339), + "executionCount": 10, + "executionCountByStatus": map[string]any{ + "success": 8, + "failure": 2, + }, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.GetWorkflowSummary(context.Background(), "wf-uuid-1", from) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "my-workflow", got.Name) + assert.Equal(t, 10, got.ExecutionCount) + assert.Equal(t, 8, got.SuccessCount) + assert.Equal(t, 2, got.FailureCount) + require.NotNil(t, got.ExecutedAt) +} + +func TestGetWorkflowSummary_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlData(w, map[string]any{ + "workflow": map[string]any{"data": nil}, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + _, err := client.GetWorkflowSummary(context.Background(), "missing", time.Now().UTC()) + require.Error(t, err) + assert.Contains(t, err.Error(), `workflow "missing" not found`) +} + +func TestGetWorkflowSummary_FallsBackWhenExecutionBreakdownUnavailable(t *testing.T) { + registered := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) + from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + var calls int + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]any{{ + "message": "the requested element is null which the schema does not allow", + }}, + }) + return + } + + gqlData(w, map[string]any{ + "workflow": map[string]any{ + "data": map[string]any{ + "uuid": "wf-uuid-1", + "name": "my-workflow", + "workflowId": "abc123onchain", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + "registeredAt": registered.Format(time.RFC3339), + "executionCount": 7, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.GetWorkflowSummary(context.Background(), "wf-uuid-1", from) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, 7, got.ExecutionCount) + assert.Equal(t, 0, got.SuccessCount) + assert.Equal(t, 0, got.FailureCount) + assert.Equal(t, 2, calls) +} + +func TestGetLatestDeployment_ReturnsMostRecent(t *testing.T) { + deployed := time.Date(2026, 1, 10, 11, 55, 0, 0, time.UTC) + from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC) + txHash := "0xabc" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + vars, _ := body["variables"].(map[string]any) + input, _ := vars["input"].(map[string]any) + assert.Equal(t, "wf-uuid-1", input["workflowUUID"]) + assert.Equal(t, from.UTC().Format(time.RFC3339), input["from"]) + assert.Equal(t, to.UTC().Format(time.RFC3339), input["to"]) + + gqlData(w, map[string]any{ + "workflowDeployments": map[string]any{ + "data": []any{ + map[string]any{ + "uuid": "dep-uuid-1", + "workflowID": "abc123onchain", + "status": "SUCCESS", + "deployedAt": deployed.Format(time.RFC3339), + "txHash": txHash, + }, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.GetLatestDeployment(context.Background(), "wf-uuid-1", from, to) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "SUCCESS", got.Status) + require.NotNil(t, got.TxHash) + assert.Equal(t, txHash, *got.TxHash) +} + +func TestGetLatestDeployment_EmptyReturnsNil(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlData(w, map[string]any{ + "workflowDeployments": map[string]any{"data": []any{}}, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.GetLatestDeployment(context.Background(), "wf-uuid-1", time.Now().UTC(), time.Now().UTC()) + require.NoError(t, err) + assert.Nil(t, got) +} diff --git a/internal/client/workflowdataclient/workflowdataclient.go b/internal/client/workflowdataclient/workflowdataclient.go index 5c9fb087..3a412c88 100644 --- a/internal/client/workflowdataclient/workflowdataclient.go +++ b/internal/client/workflowdataclient/workflowdataclient.go @@ -3,6 +3,7 @@ package workflowdataclient import ( "context" "fmt" + "strings" "time" "github.com/machinebox/graphql" @@ -15,6 +16,7 @@ const DefaultPageSize = 100 // Workflow is a workflow row returned by the platform list API. type Workflow struct { + UUID string Name string WorkflowID string OwnerAddress string @@ -46,6 +48,7 @@ const listWorkflowsQuery = ` query ListWorkflows($input: WorkflowsInput!) { workflows(input: $input) { data { + uuid name workflowId ownerAddress @@ -58,6 +61,7 @@ query ListWorkflows($input: WorkflowsInput!) { ` type gqlWorkflow struct { + UUID string `json:"uuid"` Name string `json:"name"` WorkflowID string `json:"workflowId"` OwnerAddress string `json:"ownerAddress"` @@ -72,18 +76,34 @@ type listWorkflowsEnvelope struct { } `json:"workflows"` } +// ListFilter controls optional server-side filters for ListWorkflows. +type ListFilter struct { + Search string + WorkflowOwnerAddress string +} + // ListAll pages through the ListWorkflows query and returns all workflows. func (c *Client) ListAll(parent context.Context, pageSize int) ([]Workflow, error) { - return c.list(parent, pageSize, "") + return c.ListWithFilter(parent, pageSize, ListFilter{}) } // SearchByName pages through the ListWorkflows query with the given search -// filter (server-side contains match on workflow name). -func (c *Client) SearchByName(parent context.Context, name string, pageSize int) ([]Workflow, error) { - return c.list(parent, pageSize, name) +// filter (server-side contains match on workflow name). When ownerAddress is +// non-empty, results are scoped to that workflow owner. +func (c *Client) SearchByName(parent context.Context, name string, pageSize int, ownerAddress string) ([]Workflow, error) { + return c.ListWithFilter(parent, pageSize, ListFilter{ + Search: name, + WorkflowOwnerAddress: ownerAddress, + }) } -func (c *Client) list(parent context.Context, pageSize int, search string) ([]Workflow, error) { +// ListWithFilter pages through the ListWorkflows query with optional search +// and workflow-owner filters. +func (c *Client) ListWithFilter(parent context.Context, pageSize int, filter ListFilter) ([]Workflow, error) { + return c.list(parent, pageSize, filter) +} + +func (c *Client) list(parent context.Context, pageSize int, filter ListFilter) ([]Workflow, error) { ctx, cancel := c.CreateServiceContextWithTimeout(parent) defer cancel() if pageSize <= 0 { @@ -101,8 +121,11 @@ func (c *Client) list(parent context.Context, pageSize int, search string) ([]Wo "size": pageSize, }, } - if search != "" { - input["search"] = search + if filter.Search != "" { + input["search"] = filter.Search + } + if owner := strings.TrimSpace(filter.WorkflowOwnerAddress); owner != "" { + input["workflowOwnerAddress"] = []string{owner} } req.Var("input", input) @@ -125,6 +148,10 @@ func (c *Client) list(parent context.Context, pageSize int, search string) ([]Wo } } - c.log.Debug().Int("count", len(all)).Str("search", search).Msg("Listed workflows from platform") + c.log.Debug(). + Int("count", len(all)). + Str("search", filter.Search). + Str("workflowOwnerAddress", filter.WorkflowOwnerAddress). + Msg("Listed workflows from platform") return all, nil } diff --git a/internal/client/workflowdataclient/workflowdataclient_test.go b/internal/client/workflowdataclient/workflowdataclient_test.go index 370d7874..358b5623 100644 --- a/internal/client/workflowdataclient/workflowdataclient_test.go +++ b/internal/client/workflowdataclient/workflowdataclient_test.go @@ -148,6 +148,47 @@ func TestListAll_Pagination(t *testing.T) { assert.Equal(t, int32(2), callCount.Load(), "expected exactly 2 HTTP calls for 2 pages") } +func TestSearchByName_OwnerAddress(t *testing.T) { + const owner = "c96ca1860ed10e4a484a3f1b39b86769ae7e9772" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + vars, _ := body["variables"].(map[string]any) + input, _ := vars["input"].(map[string]any) + owners, _ := input["workflowOwnerAddress"].([]any) + require.Len(t, owners, 1) + assert.Equal(t, owner, owners[0]) + assert.Equal(t, "my-workflow", input["search"]) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": 1, + "data": []map[string]string{ + { + "uuid": "wf-1", + "name": "my-workflow", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": owner, + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.SearchByName(context.Background(), "my-workflow", DefaultPageSize, owner) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, owner, got[0].OwnerAddress) +} + func TestListAll_GQLError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/constants/constants.go b/internal/constants/constants.go index ef954482..10a6e068 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -58,8 +58,8 @@ const ( WorkflowLanguageWasm = "wasm" // SDK dependency versions (used by generate-bindings and go module init) - SdkVersion = "v1.11.0" - EVMCapabilitiesVersion = "v1.0.0-beta.12" + SdkVersion = "v1.15.0" + EVMCapabilitiesVersion = "v1.0.0-beta.15" HTTPCapabilitiesVersion = "v1.3.0" CronCapabilitiesVersion = "v1.3.0" SolanaCapabilitiesVersion = "v0.1.0-beta.1" diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 2f9e2dc2..0c81d6dc 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -13,6 +13,7 @@ import ( "gopkg.in/yaml.v2" "github.com/smartcontractkit/cre-cli/internal/creconfig" + "github.com/smartcontractkit/cre-cli/internal/redact" ) type CreLoginTokenSet struct { @@ -158,7 +159,9 @@ func (c *Credentials) decodeJWTClaims() (map[string]interface{}, error) { return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err) } - c.log.Debug().Interface("claims", claims).Msg("JWT claims decoded") + if safeClaims := redact.SafeJWTClaimsForLog(claims); safeClaims != nil { + c.log.Debug().Interface("claims", safeClaims).Msg("JWT claims decoded") + } return claims, nil } diff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go index b679c12b..5d424809 100644 --- a/internal/credentials/credentials_test.go +++ b/internal/credentials/credentials_test.go @@ -1,11 +1,14 @@ package credentials import ( + "bytes" "os" "path/filepath" "strings" "testing" + "github.com/rs/zerolog" + "github.com/smartcontractkit/cre-cli/internal/creconfig" "github.com/smartcontractkit/cre-cli/internal/testutil" "github.com/smartcontractkit/cre-cli/internal/testutil/testjwt" @@ -437,3 +440,35 @@ func TestSecureRemove(t *testing.T) { } }) } + +func TestDecodeJWTClaims_SafeDebugLogging(t *testing.T) { + var buf bytes.Buffer + logger := zerolog.New(&buf).Level(zerolog.DebugLevel) + token := createTestJWT(map[string]interface{}{ + "sub": "user123", + "org_id": "org456", + "https://api.cre.chain.link/email": "test@example.com", + "https://api.cre.chain.link/organization_status": "FULL_ACCESS", + }) + + creds := &Credentials{ + AuthType: AuthTypeBearer, + Tokens: &CreLoginTokenSet{AccessToken: token}, + log: &logger, + } + + if _, err := creds.decodeJWTClaims(); err != nil { + t.Fatalf("decodeJWTClaims: %v", err) + } + + logOutput := buf.String() + if strings.Contains(logOutput, "test@example.com") { + t.Fatalf("debug log leaked email claim: %s", logOutput) + } + if !strings.Contains(logOutput, "org456") { + t.Fatalf("expected safe org_id in debug log, got: %s", logOutput) + } + if strings.Contains(logOutput, token) { + t.Fatalf("debug log leaked raw JWT token: %s", logOutput) + } +} diff --git a/internal/oauth/secrets_callback.go b/internal/oauth/secrets_callback.go index cb7f93af..b0d3a356 100644 --- a/internal/oauth/secrets_callback.go +++ b/internal/oauth/secrets_callback.go @@ -7,8 +7,7 @@ import ( ) // SecretsCallbackHandler handles the OAuth redirect for the browser secrets flow. -// If expectedState is non-empty (parsed from the platform authorize URL), the callback -// must include the same state; otherwise only a non-empty authorization code is required. +// The callback must include a state value matching expectedState (locally generated and bound to the flow). func SecretsCallbackHandler(codeCh chan<- string, expectedState string, log *zerolog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { errorParam := r.URL.Query().Get("error") @@ -20,12 +19,10 @@ func SecretsCallbackHandler(codeCh chan<- string, expectedState string, log *zer return } - if expectedState != "" { - if st := r.URL.Query().Get("state"); st != expectedState { - log.Error().Str("got", st).Str("want", expectedState).Msg("invalid state in secrets callback") - ServeEmbeddedHTML(log, w, PageSecretsError, http.StatusBadRequest) - return - } + if st := r.URL.Query().Get("state"); st == "" || expectedState == "" || st != expectedState { + log.Error().Str("got", st).Str("want", expectedState).Msg("invalid state in secrets callback") + ServeEmbeddedHTML(log, w, PageSecretsError, http.StatusBadRequest) + return } code := r.URL.Query().Get("code") diff --git a/internal/oauth/secrets_callback_test.go b/internal/oauth/secrets_callback_test.go index e7071dab..4900c3ed 100644 --- a/internal/oauth/secrets_callback_test.go +++ b/internal/oauth/secrets_callback_test.go @@ -52,15 +52,36 @@ func TestSecretsCallbackHandler_oauthError(t *testing.T) { assert.Len(t, codeCh, 0) } -func TestSecretsCallbackHandler_noStateRequired(t *testing.T) { +func TestSecretsCallbackHandler_missingState(t *testing.T) { log := zerolog.Nop() codeCh := make(chan string, 1) - h := SecretsCallbackHandler(codeCh, "", &log) + h := SecretsCallbackHandler(codeCh, "want-state", &log) req := httptest.NewRequest(http.MethodGet, "/callback?code=only-code", nil) rr := httptest.NewRecorder() h(rr, req) - assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, "only-code", <-codeCh) + assert.Equal(t, http.StatusBadRequest, rr.Code) + select { + case <-codeCh: + t.Fatal("expected no code") + default: + } +} + +func TestSecretsCallbackHandler_emptyExpectedState(t *testing.T) { + log := zerolog.Nop() + codeCh := make(chan string, 1) + h := SecretsCallbackHandler(codeCh, "", &log) + + req := httptest.NewRequest(http.MethodGet, "/callback?code=only-code&state=some-state", nil) + rr := httptest.NewRecorder() + h(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + select { + case <-codeCh: + t.Fatal("expected no code") + default: + } } diff --git a/internal/oauth/state.go b/internal/oauth/state.go index bf0de0ec..2a644d1c 100644 --- a/internal/oauth/state.go +++ b/internal/oauth/state.go @@ -16,6 +16,21 @@ func RandomState() (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } +// AuthorizeURLWithState sets or replaces the OAuth "state" query parameter on an authorize URL. +func AuthorizeURLWithState(rawURL, state string) (string, error) { + if state == "" { + return "", fmt.Errorf("oauth: state must not be empty") + } + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + q := u.Query() + q.Set("state", state) + u.RawQuery = q.Encode() + return u.String(), nil +} + // StateFromAuthorizeURL returns the OAuth "state" query parameter from an authorize URL, if present. func StateFromAuthorizeURL(raw string) (string, error) { u, err := url.Parse(raw) diff --git a/internal/oauth/state_test.go b/internal/oauth/state_test.go index fba0e450..70c6a130 100644 --- a/internal/oauth/state_test.go +++ b/internal/oauth/state_test.go @@ -16,6 +16,32 @@ func TestRandomState(t *testing.T) { assert.NotEqual(t, s, s2) } +func TestAuthorizeURLWithState(t *testing.T) { + t.Run("adds state to URL without existing state", func(t *testing.T) { + out, err := AuthorizeURLWithState("https://id.example/authorize?client_id=x&response_type=code", "local-state") + require.NoError(t, err) + assert.Contains(t, out, "state=local-state") + assert.Contains(t, out, "client_id=x") + }) + + t.Run("replaces existing state", func(t *testing.T) { + out, err := AuthorizeURLWithState("https://id.example/authorize?state=platform&client_id=x", "local-state") + require.NoError(t, err) + assert.Contains(t, out, "state=local-state") + assert.NotContains(t, out, "state=platform") + }) + + t.Run("rejects empty state", func(t *testing.T) { + _, err := AuthorizeURLWithState("https://id.example/authorize", "") + assert.Error(t, err) + }) + + t.Run("rejects invalid URL", func(t *testing.T) { + _, err := AuthorizeURLWithState("://bad", "local-state") + assert.Error(t, err) + }) +} + func TestStateFromAuthorizeURL(t *testing.T) { s, err := StateFromAuthorizeURL("https://id.example/authorize?state=abc&client_id=x") require.NoError(t, err) diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 00000000..ee2d749f --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,213 @@ +package redact + +import ( + "path/filepath" + "regexp" + "strings" +) + +const RedactedValue = "[REDACTED]" + +// maxFlagValueLength bounds the length of a flag value that is otherwise considered +// safe to record. A well-formed CLI flag value (identifier, path, enum, number) is +// always well under this; anything longer is more likely to be output, an error +// message, or a payload that ended up in a flag's Value by accident. +const maxFlagValueLength = 256 + +var ( + fullRedactFlags = map[string]struct{}{ + "env": {}, + "public-env": {}, + "http-payload": {}, + "public_key": {}, + "ledger-derivation-path": {}, + "config": {}, + "changeset-file": {}, + } + + // pathRedactFlags hold filesystem paths. Only the base name is kept, since the + // full path can leak local directory structure or the OS username. + pathRedactFlags = map[string]struct{}{ + "project-root": {}, + } + + urlValueFlags = map[string]struct{}{ + "rpc-url": {}, + "wasm": {}, + } + + jwtSegmentPattern = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) + bearerPattern = regexp.MustCompile(`(?i)(Bearer\s+)[^\s]+`) + apikeyPattern = regexp.MustCompile(`(?i)(Apikey\s+)[^\s]+`) + envSecretPattern = regexp.MustCompile(`(?i)(CRE_API_KEY\s*=\s*)\S+`) + privateKeyPattern = regexp.MustCompile(`(?i)\b(?:0x)?[0-9a-f]{64}\b`) + urlPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) + + templateAddSensitivePattern = regexp.MustCompile(`(?i)(://|\?|ghp_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+)`) + + // suspiciousContentPattern matches text that looks like it came from command + // output, an error/stack trace, or an HTTP exchange rather than a value a user + // would type as a CLI flag. + suspiciousContentPattern = regexp.MustCompile(`(?i)(traceback|panic:|goroutine \d|exception|stack trace|"error"\s*:|http/1\.[01]\s+\d{3}| maxFlagValueLength { + return true + } + if strings.ContainsAny(value, "\n\r") { + return true + } + trimmed := strings.TrimSpace(value) + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + return true + } + return suspiciousContentPattern.MatchString(value) +} + +func isNonSensitiveLimitsValue(value string) bool { + switch strings.ToLower(value) { + case "default", "none": + return true + default: + return false + } +} + +func redactURLFlagValue(name, value string) string { + if name == "rpc-url" { + chainName, rpcURL, ok := strings.Cut(value, "=") + if !ok { + return RedactedValue + } + if !looksLikeURL(rpcURL) { + return value + } + return chainName + "=" + URL(rpcURL) + } + + if looksLikeURL(value) { + return URL(value) + } + return value +} + +func looksLikeURL(value string) bool { + return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") +} + +// Args applies command-specific redaction to positional arguments. +func Args(action, subcommand string, args []string) []string { + if len(args) == 0 { + return args + } + + redacted := make([]string, len(args)) + copy(redacted, args) + + switch action { + case "secrets": + for i, arg := range redacted { + redacted[i] = filepath.Base(arg) + } + case "templates": + if subcommand == "add" { + for i, arg := range redacted { + if templateAddSensitivePattern.MatchString(arg) { + redacted[i] = RedactedValue + } + } + } + } + + // Backstop: positional args are otherwise passed through verbatim, so guard + // against output/error/payload text that ended up on the command line. + for i, arg := range redacted { + if arg != RedactedValue && looksLikeUnintendedContent(arg) { + redacted[i] = RedactedValue + } + } + + return redacted +} + +// ErrorMessage scrubs known secret patterns from error strings before telemetry export. +func ErrorMessage(msg string) string { + if msg == "" { + return msg + } + + msg = jwtSegmentPattern.ReplaceAllString(msg, RedactedValue) + msg = bearerPattern.ReplaceAllString(msg, "${1}"+RedactedValue) + msg = apikeyPattern.ReplaceAllString(msg, "${1}"+RedactedValue) + msg = envSecretPattern.ReplaceAllString(msg, "${1}"+RedactedValue) + msg = privateKeyPattern.ReplaceAllString(msg, RedactedValue) + msg = urlPattern.ReplaceAllStringFunc(msg, func(raw string) string { + return URL(raw) + }) + + return msg +} + +// SafeJWTClaimsForLog returns an allowlisted subset of JWT claims safe for debug logging. +func SafeJWTClaimsForLog(claims map[string]interface{}) map[string]interface{} { + if len(claims) == 0 { + return nil + } + + safe := make(map[string]interface{}) + for key, value := range claims { + switch key { + case "org_id", "sub", "exp", "iat", "iss", "aud": + safe[key] = value + default: + if strings.HasSuffix(key, "organization_status") || strings.HasSuffix(key, "organization_roles") { + safe[claimLogKey(key)] = value + } + } + } + + if len(safe) == 0 { + return nil + } + return safe +} + +func claimLogKey(key string) string { + if idx := strings.LastIndex(key, "/"); idx >= 0 { + return key[idx+1:] + } + return key +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 00000000..a8546626 --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,226 @@ +package redact + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "masks last path segment", + raw: "https://rpc.example.com/v1/my-secret-key", + want: "https://rpc.example.com/v1/***", + }, + { + name: "removes query params", + raw: "https://rpc.example.com/v1/key?token=secret", + want: "https://rpc.example.com/v1/***", + }, + { + name: "single path segment masked", + raw: "https://rpc.example.com/key", + want: "https://rpc.example.com/***", + }, + { + name: "no path", + raw: "https://rpc.example.com", + want: "https://rpc.example.com", + }, + { + name: "invalid URL", + raw: "://bad", + want: RedactedValue, + }, + { + name: "redacts userinfo", + raw: "http://user:secret@rpc.example.com/v1/key", + want: "http://user:***@rpc.example.com/v1/***", + }, + { + name: "redacts userinfo without path", + raw: "http://user:secret@rpc.example.com", + want: "http://user:***@rpc.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, URL(tt.raw)) + }) + } +} + +func TestFlag(t *testing.T) { + tests := []struct { + name string + flagName string + value string + want string + }{ + {name: "env path", flagName: "env", value: "/home/user/project/.env", want: RedactedValue}, + {name: "public env path", flagName: "public-env", value: "/tmp/.env.public", want: RedactedValue}, + {name: "http payload", flagName: "http-payload", value: `{"secret":"value"}`, want: RedactedValue}, + {name: "public key flag", flagName: "public_key", value: "0xabc", want: RedactedValue}, + {name: "config path", flagName: "config", value: "./config.yaml", want: RedactedValue}, + {name: "limits default", flagName: "limits", value: "default", want: "default"}, + {name: "limits none", flagName: "limits", value: "none", want: "none"}, + {name: "limits file path", flagName: "limits", value: "./limits.json", want: RedactedValue}, + {name: "rpc url with secret", flagName: "rpc-url", value: "ethereum=https://rpc.example.com/v1/secret-key", want: "ethereum=https://rpc.example.com/v1/***"}, + {name: "wasm local path", flagName: "wasm", value: "./binary.wasm", want: "./binary.wasm"}, + {name: "wasm remote url", flagName: "wasm", value: "https://cdn.example.com/wasm/secret", want: "https://cdn.example.com/wasm/***"}, + {name: "benign flag", flagName: "verbose", value: "true", want: "true"}, + {name: "empty value", flagName: "env", value: "", want: ""}, + {name: "changeset file path", flagName: "changeset-file", value: "./changeset.yaml", want: RedactedValue}, + {name: "project root basename", flagName: "project-root", value: "/home/user/my-project", want: "my-project"}, + {name: "unintended json blob on arbitrary flag", flagName: "owner-label", value: `{"key":"value"}`, want: RedactedValue}, + {name: "unintended multiline output on arbitrary flag", flagName: "run", value: "line one\nline two", want: RedactedValue}, + {name: "unintended stack trace on arbitrary flag", flagName: "run", value: "panic: runtime error", want: RedactedValue}, + {name: "unintended oversized value on arbitrary flag", flagName: "run", value: strings.Repeat("a", maxFlagValueLength+1), want: RedactedValue}, + {name: "benign free-form value passes through", flagName: "owner-label", value: "my-team", want: "my-team"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, Flag(tt.flagName, tt.value)) + }) + } +} + +func TestArgs(t *testing.T) { + tests := []struct { + name string + action string + subcommand string + args []string + want []string + }{ + { + name: "secrets basename", + action: "secrets", + subcommand: "create", + args: []string{"/home/user/project/secrets.yaml"}, + want: []string{"secrets.yaml"}, + }, + { + name: "templates add benign repo", + action: "templates", + subcommand: "add", + args: []string{"smartcontractkit/cre-templates"}, + want: []string{"smartcontractkit/cre-templates"}, + }, + { + name: "templates add url with token", + action: "templates", + subcommand: "add", + args: []string{"https://github.com/org/repo?token=secret"}, + want: []string{RedactedValue}, + }, + { + name: "workflow get unchanged", + action: "workflow", + subcommand: "get", + args: []string{"my-workflow"}, + want: []string{"my-workflow"}, + }, + { + name: "unintended json blob arg redacted", + action: "workflow", + subcommand: "get", + args: []string{`{"unexpected":"payload"}`}, + want: []string{RedactedValue}, + }, + { + name: "unintended multiline arg redacted", + action: "workflow", + subcommand: "get", + args: []string{"line one\nline two"}, + want: []string{RedactedValue}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, Args(tt.action, tt.subcommand, tt.args)) + }) + } +} + +func TestErrorMessage(t *testing.T) { + jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + + tests := []struct { + name string + msg string + want string + }{ + { + name: "jwt segment", + msg: "auth failed: token " + jwt, + want: "auth failed: token " + RedactedValue, + }, + { + name: "bearer token", + msg: "request failed with Authorization: Bearer super-secret-token", + want: "request failed with Authorization: Bearer " + RedactedValue, + }, + { + name: "api key header", + msg: "Authorization: Apikey abc123", + want: "Authorization: Apikey " + RedactedValue, + }, + { + name: "private key hex", + msg: "invalid key ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + want: "invalid key " + RedactedValue, + }, + { + name: "rpc url in error", + msg: `dial failed: https://rpc.example.com/v1/secret-key?token=abc`, + want: `dial failed: https://rpc.example.com/v1/***`, + }, + { + name: "env api key", + msg: "missing CRE_API_KEY=super-secret", + want: "missing CRE_API_KEY=" + RedactedValue, + }, + { + name: "benign error unchanged", + msg: "workflow not found", + want: "workflow not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ErrorMessage(tt.msg)) + }) + } +} + +func TestSafeJWTClaimsForLog(t *testing.T) { + claims := map[string]interface{}{ + "sub": "user123", + "org_id": "org456", + "exp": float64(1234567890), + "https://api.cre.chain.link/email": "test@example.com", + "https://api.cre.chain.link/organization_status": "FULL_ACCESS", + "https://api.cre.chain.link/organization_roles": "ROOT", + } + + safe := SafeJWTClaimsForLog(claims) + + assert.Equal(t, "user123", safe["sub"]) + assert.Equal(t, "org456", safe["org_id"]) + assert.Equal(t, float64(1234567890), safe["exp"]) + assert.Equal(t, "FULL_ACCESS", safe["organization_status"]) + assert.Equal(t, "ROOT", safe["organization_roles"]) + assert.NotContains(t, safe, "email") + assert.NotContains(t, safe, "https://api.cre.chain.link/email") +} diff --git a/internal/redact/url.go b/internal/redact/url.go new file mode 100644 index 00000000..4b9eeb48 --- /dev/null +++ b/internal/redact/url.go @@ -0,0 +1,41 @@ +package redact + +import ( + "fmt" + "net/url" + "strings" +) + +// URL returns a version of rawURL with credentials masked to avoid leaking secrets +// that may be embedded in RPC or asset URLs. +// For example, "https://rpc.example.com/v1/my-secret-key" becomes "https://rpc.example.com/v1/***". +func URL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return RedactedValue + } + + host := u.Host + if u.User != nil { + username := u.User.Username() + if username != "" { + host = username + ":***@" + u.Hostname() + if port := u.Port(); port != "" { + host += ":" + port + } + } + } + + u.Path = strings.TrimRight(u.Path, "/") + if u.Path != "" && u.Path != "/" { + parts := strings.Split(u.Path, "/") + if len(parts) > 1 { + parts[len(parts)-1] = "***" + } + u.RawPath = "" + u.Path = strings.Join(parts, "/") + } + u.RawQuery = "" + u.Fragment = "" + return fmt.Sprintf("%s://%s%s", u.Scheme, host, u.Path) +} diff --git a/internal/rpc/cleartext.go b/internal/rpc/cleartext.go new file mode 100644 index 00000000..b945491d --- /dev/null +++ b/internal/rpc/cleartext.go @@ -0,0 +1,51 @@ +package rpc + +import ( + "fmt" + "net" + "net/url" +) + +// CleartextPolicyOptions controls cleartext RPC policy evaluation. +type CleartextPolicyOptions struct { + AllowInsecure bool +} + +// IsLoopbackHost reports whether host is a local loopback address (localhost, 127.0.0.1, ::1). +func IsLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// EvaluateCleartextRPC blocks non-localhost HTTP RPC URLs unless explicitly opted in. +// Returns a warning when cleartext use is allowed via --allow-insecure-rpc. +func EvaluateCleartextRPC(rpcURL string, opts CleartextPolicyOptions) (warnMsg string, blockErr error) { + if err := IsValidURL(rpcURL); err != nil { + return "", err + } + + parsed, err := url.Parse(rpcURL) + if err != nil { + return "", fmt.Errorf("failed to parse RPC URL: invalid format") + } + + if parsed.Scheme == "https" || IsLoopbackHost(parsed.Hostname()) { + return "", nil + } + + redacted := RedactURL(rpcURL) + if !opts.AllowInsecure { + return "", fmt.Errorf( + "cleartext RPC URL %q is not allowed; use https:// or pass --allow-insecure-rpc to opt in", + redacted, + ) + } + + return fmt.Sprintf( + "using cleartext HTTP RPC URL %q; this is insecure even with --allow-insecure-rpc", + redacted, + ), nil +} diff --git a/internal/rpc/cleartext_test.go b/internal/rpc/cleartext_test.go new file mode 100644 index 00000000..20a763db --- /dev/null +++ b/internal/rpc/cleartext_test.go @@ -0,0 +1,57 @@ +package rpc_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/rpc" +) + +func TestEvaluateCleartextRPC(t *testing.T) { + t.Run("allows https", func(t *testing.T) { + warn, err := rpc.EvaluateCleartextRPC("https://rpc.example.com/v3/key", rpc.CleartextPolicyOptions{}) + require.NoError(t, err) + require.Empty(t, warn) + }) + + t.Run("allows loopback http", func(t *testing.T) { + warn, err := rpc.EvaluateCleartextRPC("http://127.0.0.1:8545", rpc.CleartextPolicyOptions{}) + require.NoError(t, err) + require.Empty(t, warn) + }) + + t.Run("blocks remote http without opt-in", func(t *testing.T) { + _, err := rpc.EvaluateCleartextRPC("http://rpc.example.com", rpc.CleartextPolicyOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "--allow-insecure-rpc") + }) + + t.Run("blocks remote http with path key without opt-in", func(t *testing.T) { + _, err := rpc.EvaluateCleartextRPC("http://rpc.example.com/v3/secret-key", rpc.CleartextPolicyOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "--allow-insecure-rpc") + }) + + t.Run("allows remote http with opt-in", func(t *testing.T) { + warn, err := rpc.EvaluateCleartextRPC("http://rpc.example.com/v3/secret-key", rpc.CleartextPolicyOptions{ + AllowInsecure: true, + }) + require.NoError(t, err) + require.Contains(t, warn, "insecure") + }) + + t.Run("rejects invalid URL", func(t *testing.T) { + _, err := rpc.EvaluateCleartextRPC("ftp://rpc.example.com", rpc.CleartextPolicyOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid scheme") + }) +} + +func TestIsLoopbackHost(t *testing.T) { + require.True(t, rpc.IsLoopbackHost("localhost")) + require.True(t, rpc.IsLoopbackHost("127.0.0.1")) + require.True(t, rpc.IsLoopbackHost("::1")) + require.False(t, rpc.IsLoopbackHost("rpc.example.com")) + require.False(t, rpc.IsLoopbackHost("10.0.0.1")) +} diff --git a/internal/rpc/redact.go b/internal/rpc/redact.go new file mode 100644 index 00000000..5eafa595 --- /dev/null +++ b/internal/rpc/redact.go @@ -0,0 +1,9 @@ +package rpc + +import "github.com/smartcontractkit/cre-cli/internal/redact" + +// RedactURL returns a version of the URL with credentials masked to avoid leaking +// secrets that may have been resolved from environment variables. +func RedactURL(rawURL string) string { + return redact.URL(rawURL) +} diff --git a/internal/runtime/runtime_context.go b/internal/runtime/runtime_context.go index a244cad2..21a936eb 100644 --- a/internal/runtime/runtime_context.go +++ b/internal/runtime/runtime_context.go @@ -153,7 +153,7 @@ func (ctx *Context) ValidateOnchainRegistryRPC() error { if ctx.ResolvedRegistry != nil && ctx.ResolvedRegistry.Type() == settings.RegistryTypeOffChain { return nil } - return settings.ValidateDeploymentRPC(&ctx.Settings.Workflow, ctx.EnvironmentSet.WorkflowRegistryChainName) + return settings.ValidateDeploymentRPC(&ctx.Settings.Workflow, ctx.EnvironmentSet.WorkflowRegistryChainName, ctx.Logger, nil) } // AttachResolvedRegistry resolves the deployment-registry from workflow diff --git a/internal/settings/multisig_validation.go b/internal/settings/multisig_validation.go new file mode 100644 index 00000000..cb700609 --- /dev/null +++ b/internal/settings/multisig_validation.go @@ -0,0 +1,29 @@ +package settings + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// IsMultisigMode reports whether multisig transaction mode is active via --unsigned or --changeset. +func IsMultisigMode(v *viper.Viper) bool { + return v.GetBool(Flags.RawTxFlag.Name) || + v.GetBool(Flags.Changeset.Name) +} + +// ValidateMultisigCompatibility rejects incompatible multisig, private registry, and browser secrets auth combinations. +// resolvedRegistry may be nil during initial settings load; the private-registry check is skipped until resolved. +func ValidateMultisigCompatibility(v *viper.Viper, cmd *cobra.Command, resolvedRegistry ResolvedRegistry) error { + if !IsMultisigMode(v) { + return nil + } + if f := cmd.Flags().Lookup("secrets-auth"); f != nil && f.Value.String() == "browser" { + return fmt.Errorf("browser secrets auth cannot be combined with multisig secrets operations; remove --unsigned/--changeset or use --secrets-auth=onchain") + } + if resolvedRegistry != nil && resolvedRegistry.Type() == RegistryTypeOffChain { + return fmt.Errorf("multisig operations (--unsigned or --changeset) are not supported with private registry; remove the flag or use an on-chain deployment-registry") + } + return nil +} diff --git a/internal/settings/multisig_validation_test.go b/internal/settings/multisig_validation_test.go new file mode 100644 index 00000000..dfcbcf9d --- /dev/null +++ b/internal/settings/multisig_validation_test.go @@ -0,0 +1,84 @@ +package settings + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsMultisigMode(t *testing.T) { + t.Run("unsigned", func(t *testing.T) { + v := viper.New() + v.Set(Flags.RawTxFlag.Name, true) + assert.True(t, IsMultisigMode(v)) + }) + + t.Run("changeset", func(t *testing.T) { + v := viper.New() + v.Set(Flags.Changeset.Name, true) + assert.True(t, IsMultisigMode(v)) + }) + + t.Run("neither flag", func(t *testing.T) { + v := viper.New() + assert.False(t, IsMultisigMode(v)) + }) +} + +func secretsCmd(secretsAuth string) *cobra.Command { + cmd := &cobra.Command{Use: "create"} + cmd.Flags().String("secrets-auth", secretsAuth, "auth mode") + return cmd +} + +func workflowCmd() *cobra.Command { + return &cobra.Command{Use: "deploy"} +} + +func TestValidateMultisigCompatibility(t *testing.T) { + offChain := NewOffChainRegistry("42", "test-don") + onChain := NewOnChainRegistry("mainnet", "0xabc", "ethereum-mainnet", "test-don", "") + + tests := []struct { + name string + unsigned bool + changeset bool + cmd *cobra.Command + resolvedRegistry ResolvedRegistry + wantErr bool + errMsg string + }{ + {"not multisig secrets onchain off-chain", false, false, secretsCmd("onchain"), offChain, false, ""}, + {"not multisig secrets browser off-chain", false, false, secretsCmd("browser"), offChain, false, ""}, + {"unsigned secrets onchain on-chain", true, false, secretsCmd("onchain"), onChain, false, ""}, + {"unsigned secrets onchain nil registry", true, false, secretsCmd("onchain"), nil, false, ""}, + {"changeset secrets browser off-chain", false, true, secretsCmd("browser"), offChain, true, "browser secrets auth cannot be combined with multisig"}, + {"unsigned secrets browser on-chain", true, false, secretsCmd("browser"), onChain, true, "browser secrets auth cannot be combined with multisig"}, + {"unsigned secrets onchain off-chain", true, false, secretsCmd("onchain"), offChain, true, "not supported with private registry"}, + {"changeset workflow off-chain", false, true, workflowCmd(), offChain, true, "not supported with private registry"}, + {"unsigned workflow on-chain", true, false, workflowCmd(), onChain, false, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := viper.New() + if tt.unsigned { + v.Set(Flags.RawTxFlag.Name, true) + } + if tt.changeset { + v.Set(Flags.Changeset.Name, true) + } + + err := ValidateMultisigCompatibility(v, tt.cmd, tt.resolvedRegistry) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 92958f6a..7f945e47 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -12,12 +12,13 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" + "github.com/smartcontractkit/chainlink-common/keystore/corekeys" "github.com/smartcontractkit/cre-cli/internal/ui" ) const CreTargetEnvVar = "CRE_TARGET" +const CreAllowInsecureRPCEnvVar = "CRE_ALLOW_INSECURE_RPC" // ChainType describes a chain family and the per-family settings the CLI // loads from the environment. Add a family by appending to AllChainTypes. @@ -31,18 +32,24 @@ var ( Name: string(corekeys.EVM), PrivateKeyEnv: "CRE_ETH_PRIVATE_KEY", } + Solana = ChainType{ + Name: string(corekeys.Solana), + PrivateKeyEnv: "CRE_SOLANA_PRIVATE_KEY", + } Aptos = ChainType{ Name: string(corekeys.Aptos), PrivateKeyEnv: "CRE_APTOS_PRIVATE_KEY", } - AllChainTypes = []ChainType{EVM, Aptos} + AllChainTypes = []ChainType{EVM, Solana, Aptos} ) -// Backwards-compat aliases; prefer EVM.PrivateKeyEnv / Aptos.PrivateKeyEnv. +// Backwards-compat aliases; prefer EVM.PrivateKeyEnv / Aptos.PrivateKeyEnv / +// Solana.PrivateKeyEnv. var ( - EthPrivateKeyEnvVar = EVM.PrivateKeyEnv - AptosPrivateKeyEnvVar = Aptos.PrivateKeyEnv + EthPrivateKeyEnvVar = EVM.PrivateKeyEnv + SolanaPrivateKeyEnvVar = Solana.PrivateKeyEnv + AptosPrivateKeyEnvVar = Aptos.PrivateKeyEnv ) // State tracked by LoadEnv / LoadPublicEnv so downstream code (e.g. build @@ -134,17 +141,13 @@ func New(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Command, registryCha privateKeys := make(map[string]string, len(AllChainTypes)) for _, f := range AllChainTypes { - raw := v.GetString(f.PrivateKeyEnv) - if isPrivateKeyEnvPlaceholder(raw) { - continue - } - privateKeys[f.Name] = NormalizeHexKey(raw) + privateKeys[f.Name] = NormalizeHexKey(v.GetString(f.PrivateKeyEnv)) } return &Settings{ User: UserSettings{ - TargetName: target, PrivateKeys: privateKeys, + TargetName: target, }, Workflow: workflowSettings, StorageSettings: storageSettings, @@ -185,7 +188,7 @@ func loadEnvFile(logger *zerolog.Logger, envPath string) (string, map[string]str } err = os.Setenv(k, v) if err != nil { - logger.Error().Str("key", k).Str("value", v).Err(err).Msg( + logger.Error().Str("key", k).Err(err).Msg( "Failed to set environment variable; CLI tool will read and verify individual environment variables (they MUST be exported). " + "If the file is present, please check that it follows the correct format: https://dotenvx.com/docs/env-file") } @@ -218,11 +221,12 @@ func LoadEnv(logger *zerolog.Logger, v *viper.Viper, envPath string) { loadedEnvFilePath = "" loadedEnvVars = nil loadedEnvFilePath, loadedEnvVars = loadEnvFile(logger, envPath) - extras := []string{CreTargetEnvVar} + extras := []string{CreTargetEnvVar, CreAllowInsecureRPCEnvVar} for _, f := range AllChainTypes { extras = append(extras, f.PrivateKeyEnv) } bindAllVars(v, loadedEnvVars, extras...) + _ = v.BindEnv(Flags.AllowInsecureRPC.Name, CreAllowInsecureRPCEnvVar) } // LoadPublicEnv loads variables from envPath into the process environment diff --git a/internal/settings/settings_generate.go b/internal/settings/settings_generate.go index a4fcbdd0..0f78c959 100644 --- a/internal/settings/settings_generate.go +++ b/internal/settings/settings_generate.go @@ -28,10 +28,11 @@ var gitIgnoreTemplateContent string var workflowSettingsTemplateContent string type ProjectEnv struct { - FilePath string - GitHubAPIToken string - EthPrivateKey string - AptosPrivateKey string + FilePath string + GitHubAPIToken string + EthPrivateKey string + SolanaPrivateKey string + AptosPrivateKey string } func GetDefaultReplacements() map[string]string { @@ -119,9 +120,10 @@ func GenerateProjectEnvFile(workingDirectory string) (string, error) { } replacements := map[string]string{ - "GithubApiToken": "your-github-token", - "EthPrivateKey": "your-eth-private-key", - "AptosPrivateKey": "your-aptos-private-key", + "GithubApiToken": "your-github-token", + "EthPrivateKey": DefaultEthPrivateKeyEnvPlaceholder, + "SolanaPrivateKey": "your-solana-private-key", + "AptosPrivateKey": "your-aptos-private-key", } if err := GenerateFileFromTemplate(outputPath, ProjectEnvironmentTemplateContent, replacements); err != nil { diff --git a/internal/settings/settings_load.go b/internal/settings/settings_load.go index 58bd6790..f14c371b 100644 --- a/internal/settings/settings_load.go +++ b/internal/settings/settings_load.go @@ -48,6 +48,7 @@ type flagNames struct { SkipConfirmation Flag ChangesetFile Flag AllowUnknownChains Flag + AllowInsecureRPC Flag } var Flags = flagNames{ @@ -66,6 +67,7 @@ var Flags = flagNames{ SkipConfirmation: Flag{"yes", "y"}, ChangesetFile: Flag{"changeset-file", ""}, AllowUnknownChains: Flag{"allow-unknown-chains", ""}, + AllowInsecureRPC: Flag{"allow-insecure-rpc", ""}, } func AddTxnTypeFlags(cmd *cobra.Command) { diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index b1ea9fd8..7eb88bbb 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -136,7 +136,7 @@ func TestLoadEnvAndSettings(t *testing.T) { s, err := settings.New(logger, v, cmd, "") require.NoError(t, err) assert.Equal(t, "staging", s.User.TargetName) - assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.PrivateKey(settings.EVM)) + assert.Equal(t, envVars[settings.EthPrivateKeyEnvVar], s.User.PrivateKey(settings.EVM)) } func TestLoadEnvAndSettingsCreInitPlaceholderPrivateKey(t *testing.T) { @@ -162,7 +162,7 @@ func TestLoadEnvAndSettingsCreInitPlaceholderPrivateKey(t *testing.T) { cmd := makeCmdWithSecretsAuth("create", "browser") s, err := settings.New(logger, v, cmd, "") require.NoError(t, err) - assert.Empty(t, s.User.PrivateKey(settings.EVM)) + assert.Equal(t, envVars[settings.EthPrivateKeyEnvVar], s.User.PrivateKey(settings.EVM)) } func TestLoadEnvAndSettingsWithWorkflowSettingsFlag(t *testing.T) { @@ -195,7 +195,7 @@ func TestLoadEnvAndSettingsWithWorkflowSettingsFlag(t *testing.T) { s, err := settings.New(logger, v, cmd, "") require.NoError(t, err) assert.Equal(t, "staging", s.User.TargetName) - assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.PrivateKey(settings.EVM)) + assert.Equal(t, settings.NormalizeHexKey(envVars[settings.EthPrivateKeyEnvVar]), s.User.PrivateKey(settings.EVM)) } func TestInlineEnvTakesPrecedenceOverDotEnv(t *testing.T) { @@ -225,7 +225,7 @@ func TestInlineEnvTakesPrecedenceOverDotEnv(t *testing.T) { s, err := settings.New(logger, v, cmd, "") require.NoError(t, err) assert.Equal(t, "staging", s.User.TargetName) - assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.PrivateKey(settings.EVM)) + assert.Equal(t, envVars[settings.EthPrivateKeyEnvVar], s.User.PrivateKey(settings.EVM)) } func TestLoadEnvAndMergedSettings(t *testing.T) { @@ -267,7 +267,7 @@ func TestLoadEnvAndMergedSettings(t *testing.T) { rpc2 := s.Workflow.RPCs[1] assert.Equal(t, "https://somethingElse.rpc.org", rpc1.Url, "First RPC URL mismatch") assert.Equal(t, "https://something.rpc.org", rpc2.Url, "Second RPC URL mismatch") - assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.PrivateKey(settings.EVM)) + assert.Equal(t, envVars[settings.EthPrivateKeyEnvVar], s.User.PrivateKey(settings.EVM)) } // helper to build a command with optional --broadcast flag and parse args diff --git a/internal/settings/workflow_settings.go b/internal/settings/workflow_settings.go index 17aba9c4..4db39836 100644 --- a/internal/settings/workflow_settings.go +++ b/internal/settings/workflow_settings.go @@ -159,11 +159,11 @@ func loadWorkflowSettings(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Com workflowSettings.RPCs[i].Url = resolved } - if err := ValidateDeploymentRPC(&workflowSettings, registryChainName); err != nil { + if err := ValidateDeploymentRPC(&workflowSettings, registryChainName, logger, nil); err != nil { return WorkflowSettings{}, errors.Wrap(err, "for target "+target) } - if err := validateSettings(&workflowSettings, v.GetBool(Flags.AllowUnknownChains.Name)); err != nil { + if err := validateSettings(logger, v, cmd, &workflowSettings, v.GetBool(Flags.AllowUnknownChains.Name)); err != nil { return WorkflowSettings{}, errors.Wrap(err, "for target "+target) } @@ -260,16 +260,52 @@ func flattenWorkflowSettingsToViper(v *viper.Viper, target string, effectiveWork return nil } -func validateSettings(config *WorkflowSettings, allowUnknownChains bool) error { +func validateSettings(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Command, config *WorkflowSettings, allowUnknownChains bool) error { + cleartextOpts := cleartextPolicyOptions(v) + // TODO validate that all chain names mentioned for the contracts above have a matching URL specified - for _, rpc := range config.RPCs { - if err := isValidRpcUrl(rpc.Url); err != nil { - return errors.Wrap(err, "invalid rpc url for "+rpc.ChainName) + for _, rpcEndpoint := range config.RPCs { + if err := isValidRpcUrl(rpcEndpoint.Url); err != nil { + return errors.Wrap(err, "invalid rpc url for "+rpcEndpoint.ChainName) + } + if err := ValidateRPCCleartext(logger, rpcEndpoint.ChainName, rpcEndpoint.Url, cleartextOpts); err != nil { + return err } if allowUnknownChains { continue } - if err := IsValidChainName(rpc.ChainName); err != nil { + if err := IsValidChainName(rpcEndpoint.ChainName); err != nil { + return err + } + } + return nil +} + +func cleartextPolicyOptions(v *viper.Viper) rpc.CleartextPolicyOptions { + return rpc.CleartextPolicyOptions{ + AllowInsecure: v.GetBool(Flags.AllowInsecureRPC.Name), + } +} + +func ValidateRPCCleartext(logger *zerolog.Logger, chainName, rpcURL string, opts rpc.CleartextPolicyOptions) error { + warnMsg, blockErr := rpc.EvaluateCleartextRPC(rpcURL, opts) + if blockErr != nil { + return errors.Wrapf(blockErr, "invalid rpc url for %s", chainName) + } + if warnMsg != "" && logger != nil { + logger.Warn(). + Str("chain", chainName). + Str("url", rpc.RedactURL(rpcURL)). + Msg(warnMsg) + } + return nil +} + +// ValidateSettingsCleartext validates cleartext RPC policy for all configured RPC URLs. +func ValidateSettingsCleartext(logger *zerolog.Logger, v *viper.Viper, config *WorkflowSettings) error { + cleartextOpts := cleartextPolicyOptions(v) + for _, rpcEndpoint := range config.RPCs { + if err := ValidateRPCCleartext(logger, rpcEndpoint.ChainName, rpcEndpoint.Url, cleartextOpts); err != nil { return err } } @@ -323,18 +359,20 @@ 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. // +// When cleartext is non-nil, cleartext RPC policy is enforced for the deployment RPC URL. +// // TODO(DEVSVCS-5178) -func ValidateDeploymentRPC(config *WorkflowSettings, chainName string) error { +func ValidateDeploymentRPC(config *WorkflowSettings, chainName string, logger *zerolog.Logger, cleartext *rpc.CleartextPolicyOptions) error { if chainName == "" { return nil } deploymentRPCFound := false deploymentRPCURL := "" commonError := " - required to deploy CRE workflows" - for _, rpc := range config.RPCs { - if rpc.ChainName == chainName { + for _, rpcEndpoint := range config.RPCs { + if rpcEndpoint.ChainName == chainName { deploymentRPCFound = true - deploymentRPCURL = rpc.Url + deploymentRPCURL = rpcEndpoint.Url break } } @@ -344,5 +382,10 @@ func ValidateDeploymentRPC(config *WorkflowSettings, chainName string) error { if err := isValidRpcUrl(deploymentRPCURL); err != nil { return errors.Wrap(err, "invalid RPC URL for "+chainName+commonError) } + if cleartext != nil { + if err := ValidateRPCCleartext(logger, chainName, deploymentRPCURL, *cleartext); err != nil { + return errors.Wrap(err, "invalid RPC URL for "+chainName+commonError) + } + } return nil } diff --git a/internal/settings/workflow_settings_cleartext_test.go b/internal/settings/workflow_settings_cleartext_test.go new file mode 100644 index 00000000..b4308565 --- /dev/null +++ b/internal/settings/workflow_settings_cleartext_test.go @@ -0,0 +1,62 @@ +package settings_test + +import ( + "testing" + + "github.com/rs/zerolog" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/rpc" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func TestValidateDeploymentRPCCleartext(t *testing.T) { + t.Parallel() + + logger := zerolog.Nop() + config := &settings.WorkflowSettings{ + RPCs: []settings.RpcEndpoint{ + { + ChainName: "ethereum-testnet-sepolia", + Url: "http://rpc.example.com/v3/secret-key", + }, + }, + } + + t.Run("blocks remote cleartext without opt-in", func(t *testing.T) { + t.Parallel() + cleartext := &rpc.CleartextPolicyOptions{} + err := settings.ValidateDeploymentRPC(config, "ethereum-testnet-sepolia", &logger, cleartext) + require.Error(t, err) + require.Contains(t, err.Error(), "--allow-insecure-rpc") + }) + + t.Run("allows remote cleartext with opt-in", func(t *testing.T) { + t.Parallel() + cleartext := &rpc.CleartextPolicyOptions{AllowInsecure: true} + err := settings.ValidateDeploymentRPC(config, "ethereum-testnet-sepolia", &logger, cleartext) + require.NoError(t, err) + }) +} + +func TestValidateSettingsCleartextBlocksRemoteHTTP(t *testing.T) { + t.Parallel() + + v := viper.New() + v.Set(settings.Flags.AllowInsecureRPC.Name, false) + + logger := zerolog.Nop() + config := &settings.WorkflowSettings{ + RPCs: []settings.RpcEndpoint{ + { + ChainName: "ethereum-testnet-sepolia", + Url: "http://rpc.example.com", + }, + }, + } + + err := settings.ValidateSettingsCleartext(&logger, v, config) + require.Error(t, err) + require.Contains(t, err.Error(), "--allow-insecure-rpc") +} diff --git a/internal/telemetry/collector.go b/internal/telemetry/collector.go index 1d47dd53..ad1269da 100644 --- a/internal/telemetry/collector.go +++ b/internal/telemetry/collector.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/pflag" "github.com/smartcontractkit/cre-cli/internal/creconfig" + "github.com/smartcontractkit/cre-cli/internal/redact" ) const MachineIDFile = "machine_id" @@ -80,10 +81,9 @@ func collectFlags(cmd *cobra.Command) []KeyValuePair { // Only include flags that were explicitly set by the user // This avoids cluttering telemetry with default values if flag.Changed { - value := flag.Value.String() flags = append(flags, KeyValuePair{ Key: flag.Name, - Value: value, + Value: redact.Flag(flag.Name, flag.Value.String()), }) } }) @@ -106,7 +106,7 @@ func CollectCommandInfo(cmd *cobra.Command, args []string) CommandInfo { } // Collect args (only positional arguments, not flags) - info.Args = args + info.Args = redact.Args(info.Action, info.Subcommand, args) // Collect flags as key-value pairs (only flags explicitly set by user) info.Flags = collectFlags(cmd) diff --git a/internal/telemetry/emitter.go b/internal/telemetry/emitter.go index c9bbd506..ebe52dab 100644 --- a/internal/telemetry/emitter.go +++ b/internal/telemetry/emitter.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/smartcontractkit/cre-cli/cmd/version" + "github.com/smartcontractkit/cre-cli/internal/redact" "github.com/smartcontractkit/cre-cli/internal/runtime" ) @@ -113,7 +114,7 @@ func buildUserEvent(cmd *cobra.Command, args []string, exitCode int, runtimeCtx // Extract error message if error is present (at top level) if err != nil { - event.ErrorMessage = err.Error() + event.ErrorMessage = redact.ErrorMessage(err.Error()) } // Collect actor information (only machineId, server populates userId/orgId from JWT) diff --git a/internal/telemetry/sender.go b/internal/telemetry/sender.go index d4c23250..50d89126 100644 --- a/internal/telemetry/sender.go +++ b/internal/telemetry/sender.go @@ -11,6 +11,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/redact" ) const ( @@ -76,7 +77,7 @@ func SendEvent(ctx context.Context, event UserEventInput, creds *credentials.Cre err := client.Execute(sendCtx, req, &resp) if err != nil { - debugLog("telemetry request failed: %v", err) + debugLog("telemetry request failed: %v", redact.ErrorMessage(err.Error())) } else { debugLog("telemetry request succeeded: success=%v, message=%s", resp.ReportUserEvent.Success, resp.ReportUserEvent.Message) } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 7515c094..c5a758cb 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -1,6 +1,7 @@ package telemetry import ( + "errors" "os" "runtime" "testing" @@ -8,6 +9,8 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/redact" ) func TestCollectMachineInfo(t *testing.T) { @@ -125,6 +128,51 @@ func TestBuildUserEvent(t *testing.T) { assert.Equal(t, runtime.GOARCH, event.Machine.Architecture) } +func TestCollectCommandInfo_RedactsSensitiveFlags(t *testing.T) { + parent := &cobra.Command{Use: "workflow"} + cmd := &cobra.Command{Use: "simulate"} + parent.AddCommand(cmd) + + cmd.Flags().String("env", "", "env file") + cmd.Flags().String("http-payload", "", "payload") + cmd.Flags().String("wasm", "", "wasm path") + require.NoError(t, cmd.Flags().Set("env", "/home/user/.env")) + require.NoError(t, cmd.Flags().Set("http-payload", `{"token":"secret"}`)) + require.NoError(t, cmd.Flags().Set("wasm", "https://cdn.example.com/wasm/secret")) + + info := CollectCommandInfo(cmd, []string{"./my-workflow"}) + + assert.Equal(t, "workflow", info.Action) + assert.Equal(t, "simulate", info.Subcommand) + assert.Equal(t, []string{"./my-workflow"}, info.Args) + + flagValues := map[string]string{} + for _, flag := range info.Flags { + flagValues[flag.Key] = flag.Value + } + assert.Equal(t, redact.RedactedValue, flagValues["env"]) + assert.Equal(t, redact.RedactedValue, flagValues["http-payload"]) + assert.Equal(t, "https://cdn.example.com/wasm/***", flagValues["wasm"]) +} + +func TestCollectCommandInfo_RedactsSecretsArgs(t *testing.T) { + parent := &cobra.Command{Use: "secrets"} + cmd := &cobra.Command{Use: "create"} + parent.AddCommand(cmd) + + info := CollectCommandInfo(cmd, []string{"/home/user/project/secrets.yaml"}) + assert.Equal(t, []string{"secrets.yaml"}, info.Args) +} + +func TestBuildUserEvent_RedactsErrorMessage(t *testing.T) { + cmd := &cobra.Command{Use: "login"} + err := errors.New("auth failed: Bearer super-secret-token") + + event := buildUserEvent(cmd, []string{}, 1, nil, err) + + assert.Equal(t, "auth failed: Bearer "+redact.RedactedValue, event.ErrorMessage) +} + func TestGetOSVersion(t *testing.T) { version := getOSVersion() require.NotEmpty(t, version) diff --git a/internal/templaterepo/builtin/hello-world-ts/workflow/package.json b/internal/templaterepo/builtin/hello-world-ts/workflow/package.json index e0c1970a..60595f85 100644 --- a/internal/templaterepo/builtin/hello-world-ts/workflow/package.json +++ b/internal/templaterepo/builtin/hello-world-ts/workflow/package.json @@ -8,7 +8,7 @@ }, "license": "UNLICENSED", "dependencies": { - "@chainlink/cre-sdk": "^1.9.0" + "@chainlink/cre-sdk": "1.17.0-alpha.solana-log-trigger.1" }, "devDependencies": { "typescript": "5.9.3" diff --git a/internal/testutil/cli_home.go b/internal/testutil/cli_home.go new file mode 100644 index 00000000..87118adc --- /dev/null +++ b/internal/testutil/cli_home.go @@ -0,0 +1,84 @@ +package testutil + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +var defaultGoPath string + +func init() { + home, err := os.UserHomeDir() + if err != nil { + home = "/tmp" + } + defaultGoPath = filepath.Join(home, "go") +} + +// IsolateCLIHome redirects CLI config writes (~/.cre) into a temp directory. +// Call in tests that run the cre binary or invoke EnsureContext/FetchAndWriteContext. +func IsolateCLIHome(t *testing.T) string { + t.Helper() + + // Resolve Go cache paths before overriding HOME. os.UserHomeDir() follows $HOME, + // so reading it after t.Setenv("HOME", tempDir) would pin GOPATH inside the temp dir. + gopath, gomodcache := resolvedGoCachePaths() + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("GOPATH", gopath) + t.Setenv("GOMODCACHE", gomodcache) + return home +} + +// PinGoCacheForTestHome keeps GOPATH/GOMODCACHE outside temp HOME directories. +// Overriding HOME makes Go default GOPATH to $HOME/go; module files are read-only and break TempDir cleanup. +func PinGoCacheForTestHome(t *testing.T) { + t.Helper() + + gopath, gomodcache := resolvedGoCachePaths() + t.Setenv("GOPATH", gopath) + t.Setenv("GOMODCACHE", gomodcache) +} + +func resolvedGoCachePaths() (gopath, gomodcache string) { + gopath = os.Getenv("GOPATH") + if gopath == "" { + gopath = defaultGoPath + } + + gomodcache = os.Getenv("GOMODCACHE") + if gomodcache == "" { + gomodcache = filepath.Join(gopath, "pkg", "mod") + } + + return gopath, gomodcache +} + +// CLIChildEnv builds subprocess env with isolated HOME for credentials and pinned Go cache paths. +func CLIChildEnv(t *testing.T, testHome string) []string { + t.Helper() + + gopath, gomodcache := resolvedGoCachePaths() + + childEnv := make([]string, 0, len(os.Environ())+4) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "HOME=") || + strings.HasPrefix(entry, "USERPROFILE=") || + strings.HasPrefix(entry, "GOPATH=") || + strings.HasPrefix(entry, "GOMODCACHE=") { + continue + } + childEnv = append(childEnv, entry) + } + childEnv = append(childEnv, + "HOME="+testHome, + "USERPROFILE="+testHome, + "GOPATH="+gopath, + "GOMODCACHE="+gomodcache, + ) + return childEnv +} diff --git a/internal/testutil/graphql_mock.go b/internal/testutil/graphql_mock.go index ed582a17..34078d75 100644 --- a/internal/testutil/graphql_mock.go +++ b/internal/testutil/graphql_mock.go @@ -4,9 +4,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "testing" + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/cre-cli/internal/environments" ) @@ -30,6 +33,12 @@ func QueryIsGetTenantConfig(q string) bool { // MockGetTenantConfigGraphQLPayload returns a GraphQL response for getTenantConfig // suitable for E2E tests using the anvil-devnet workflow registry defaults. func MockGetTenantConfigGraphQLPayload() map[string]any { + return MockGetTenantConfigGraphQLPayloadWithCapReg("0x76c9cf548b4179F8901cda1f8623568b58215E62") +} + +// MockGetTenantConfigGraphQLPayloadWithCapReg returns getTenantConfig with a custom CapabilitiesRegistry address. +func MockGetTenantConfigGraphQLPayloadWithCapReg(capRegAddress string) map[string]any { + anvilSelector := strconv.FormatUint(chainselectors.ANVIL_DEVNET.Selector, 10) return map[string]any{ "data": map[string]any{ "getTenantConfig": map[string]any{ @@ -37,15 +46,15 @@ func MockGetTenantConfigGraphQLPayload() map[string]any { "defaultDonFamily": "zone-a", "vaultGatewayUrl": "https://vault.example.test", "capabilitiesRegistry": map[string]any{ - "chainSelector": "6433500567565415381", - "address": "0x76c9cf548b4179F8901cda1f8623568b58215E62", + "chainSelector": anvilSelector, + "address": capRegAddress, }, "registries": []map[string]any{ { "id": "anvil-devnet", "label": "anvil-devnet", "type": "ON_CHAIN", - "chainSelector": "6433500567565415381", + "chainSelector": anvilSelector, "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3", "secretsAuthFlows": []string{"OWNER_KEY_SIGNING"}, }, @@ -67,6 +76,8 @@ func MockGetTenantConfigGraphQLPayload() map[string]any { // It sets EnvVarGraphQLURL so CLI commands use this server. Caller must defer srv.Close(). func NewGraphQLMockServerGetOrganization(t *testing.T) *httptest.Server { t.Helper() + IsolateCLIHome(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { var req struct { diff --git a/internal/workflowresolve/execution.go b/internal/workflowresolve/execution.go new file mode 100644 index 00000000..59c8de77 --- /dev/null +++ b/internal/workflowresolve/execution.go @@ -0,0 +1,330 @@ +package workflowresolve + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// ---- List executions ---- + +type executionJSON struct { + UUID string `json:"uuid"` + WorkflowUUID string `json:"workflowUUID"` + WorkflowName string `json:"workflowName"` + Status string `json:"status"` + StartedAt string `json:"startedAt"` + FinishedAt *string `json:"finishedAt,omitempty"` + DurationSec *string `json:"duration,omitempty"` + CreditUsed *string `json:"creditUsed,omitempty"` +} + +func toExecutionJSON(e workflowdataclient.Execution) executionJSON { + started := e.StartedAt.UTC().Format(time.RFC3339) + j := executionJSON{ + UUID: e.UUID, + WorkflowUUID: e.WorkflowUUID, + WorkflowName: e.WorkflowName, + Status: string(e.Status), + StartedAt: started, + CreditUsed: e.CreditUsed, + } + if e.FinishedAt != nil { + f := e.FinishedAt.UTC().Format(time.RFC3339) + j.FinishedAt = &f + d := formatDuration(e.FinishedAt.Sub(e.StartedAt)) + j.DurationSec = &d + } + return j +} + +// PrintExecutionsJSON marshals a slice of executions as an indented JSON array to stdout. +func PrintExecutionsJSON(rows []workflowdataclient.Execution) error { + out := make([]executionJSON, 0, len(rows)) + for _, e := range rows { + out = append(out, toExecutionJSON(e)) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// PrintExecutionsTable renders executions as a bulleted list to stdout. +func PrintExecutionsTable(rows []workflowdataclient.Execution) { + ui.Line() + if len(rows) == 0 { + ui.Warning("No executions found") + ui.Line() + return + } + + ui.Bold("Executions") + ui.Line() + + for i, e := range rows { + ui.Bold(fmt.Sprintf("%d. %s", i+1, e.ID)) + ui.Dim(fmt.Sprintf(" Workflow: %s", e.WorkflowName)) + ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) + ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) + if e.FinishedAt != nil { + ui.Dim(fmt.Sprintf(" Finished: %s (%s)", e.FinishedAt.UTC().Format("2006-01-02 15:04:05 UTC"), formatDuration(e.FinishedAt.Sub(e.StartedAt)))) + } + ui.Line() + } +} + +// ---- Single execution detail (status command) ---- + +type executionDetailJSON struct { + executionJSON + Errors []executionErrorJSON `json:"errors,omitempty"` +} + +type executionErrorJSON struct { + Error string `json:"error"` + Count int `json:"count"` +} + +// PrintExecutionDetailJSON marshals a single execution with its errors and failed events to stdout. +func PrintExecutionDetailJSON(e workflowdataclient.Execution, failedEvents []workflowdataclient.ExecutionEvent) error { + errs := make([]executionErrorJSON, 0, len(e.Errors)) + for _, err := range e.Errors { + errs = append(errs, executionErrorJSON{Error: err.Error, Count: err.Count}) + } + type failedEventJSON struct { + CapabilityID string `json:"capabilityID"` + Method *string `json:"method,omitempty"` + Errors []capabilityErrorJSON `json:"errors,omitempty"` + } + fevs := make([]failedEventJSON, 0, len(failedEvents)) + for _, ev := range failedEvents { + fe := failedEventJSON{CapabilityID: ev.CapabilityID, Method: ev.Method} + for _, ce := range ev.Errors { + fe.Errors = append(fe.Errors, capabilityErrorJSON{Error: ce.Error, Count: ce.Count}) + } + fevs = append(fevs, fe) + } + type detailJSON struct { + executionDetailJSON + FailedEvents []failedEventJSON `json:"failedEvents,omitempty"` + } + detail := detailJSON{ + executionDetailJSON: executionDetailJSON{ + executionJSON: toExecutionJSON(e), + Errors: errs, + }, + FailedEvents: fevs, + } + data, err := json.MarshalIndent(detail, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// PrintExecutionDetailTable renders a single execution with failed capability events inline. +func PrintExecutionDetailTable(e workflowdataclient.Execution, failedEvents []workflowdataclient.ExecutionEvent) { + ui.Line() + ui.Bold(fmt.Sprintf("Execution: %s", e.ID)) + ui.Dim(fmt.Sprintf(" Workflow: %s", e.WorkflowName)) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", e.WorkflowID)) + ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) + + timeStr := e.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC") + if e.FinishedAt != nil { + timeStr = fmt.Sprintf("%s to %s (%s)", + e.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC"), + e.FinishedAt.UTC().Format("15:04:05 UTC"), + formatDuration(e.FinishedAt.Sub(e.StartedAt)), + ) + } + ui.Dim(fmt.Sprintf(" Time: %s", timeStr)) + + if len(e.Errors) > 0 { + ui.Line() + ui.Bold("Top-Level Errors:") + for _, err := range e.Errors { + ui.Dim(fmt.Sprintf(" - %s (Count: %d)", err.Error, err.Count)) + } + } + + if len(failedEvents) > 0 { + ui.Line() + ui.Bold("Failures:") + for _, ev := range failedEvents { + method := "" + if ev.Method != nil && *ev.Method != "" { + method = " " + *ev.Method + } + ui.Dim(fmt.Sprintf(" %s%s", ev.CapabilityID, method)) + for _, ce := range ev.Errors { + ui.Dim(fmt.Sprintf(" - %s (x%d)", ce.Error, ce.Count)) + } + } + } + + ui.Line() + ui.Bold("Debug further:") + ui.Dim(fmt.Sprintf(" cre execution events %s", e.ID)) + ui.Dim(fmt.Sprintf(" cre execution logs %s", e.ID)) + ui.Line() +} + +// ---- Events ---- + +type eventJSON struct { + CapabilityID string `json:"capabilityID"` + Status string `json:"status"` + Method *string `json:"method,omitempty"` + StartedAt string `json:"startedAt"` + FinishedAt *string `json:"finishedAt,omitempty"` + Duration *string `json:"duration,omitempty"` + Errors []capabilityErrorJSON `json:"errors,omitempty"` +} + +type capabilityErrorJSON struct { + Error string `json:"error"` + Count int `json:"count"` +} + +// PrintEventsJSON marshals events as an indented JSON array to stdout. +func PrintEventsJSON(events []workflowdataclient.ExecutionEvent) error { + out := make([]eventJSON, 0, len(events)) + for _, ev := range events { + j := eventJSON{ + CapabilityID: ev.CapabilityID, + Status: ev.Status, + Method: ev.Method, + StartedAt: ev.StartedAt.UTC().Format(time.RFC3339), + } + if ev.FinishedAt != nil { + f := ev.FinishedAt.UTC().Format(time.RFC3339) + j.FinishedAt = &f + d := formatDuration(ev.FinishedAt.Sub(ev.StartedAt)) + j.Duration = &d + } + for _, e := range ev.Errors { + j.Errors = append(j.Errors, capabilityErrorJSON{Error: e.Error, Count: e.Count}) + } + out = append(out, j) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// PrintEventsTable renders events as a bulleted list to stdout. +func PrintEventsTable(events []workflowdataclient.ExecutionEvent) { + ui.Line() + if len(events) == 0 { + ui.Warning("No events found") + ui.Line() + return + } + + ui.Bold("Events") + ui.Line() + + for i, ev := range events { + method := "-" + if ev.Method != nil && *ev.Method != "" { + method = *ev.Method + } + dur := "-" + if ev.FinishedAt != nil { + dur = formatDuration(ev.FinishedAt.Sub(ev.StartedAt)) + } + + ui.Bold(fmt.Sprintf("%d. %s", i+1, ev.CapabilityID)) + ui.Dim(fmt.Sprintf(" Method: %s", method)) + ui.Dim(fmt.Sprintf(" Status: %s", ev.Status)) + ui.Dim(fmt.Sprintf(" Started: %s", ev.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) + ui.Dim(fmt.Sprintf(" Duration: %s", dur)) + if len(ev.Errors) > 0 { + errMsgs := make([]string, 0, len(ev.Errors)) + for _, e := range ev.Errors { + errMsgs = append(errMsgs, fmt.Sprintf("%s (x%d)", e.Error, e.Count)) + } + ui.Dim(fmt.Sprintf(" Errors: %s", strings.Join(errMsgs, "; "))) + } + ui.Line() + } +} + +// ---- Logs ---- + +type logJSON struct { + NodeID string `json:"nodeID"` + Timestamp string `json:"timestamp"` + Message string `json:"message"` +} + +// PrintLogsJSON marshals logs as an indented JSON array to stdout. +// nodeFilter, if non-empty, restricts output to lines whose NodeID matches (case-insensitive). +func PrintLogsJSON(logs []workflowdataclient.ExecutionLog, nodeFilter string) error { + out := make([]logJSON, 0, len(logs)) + for _, l := range logs { + if nodeFilter != "" && !strings.EqualFold(l.NodeID, nodeFilter) { + continue + } + out = append(out, logJSON{ + NodeID: l.NodeID, + Timestamp: l.Timestamp.UTC().Format(time.RFC3339), + Message: l.Message, + }) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// PrintLogsTable renders log lines to stdout. +// nodeFilter, if non-empty, restricts output to lines whose NodeID matches (case-insensitive). +func PrintLogsTable(logs []workflowdataclient.ExecutionLog, nodeFilter string) { + ui.Line() + printed := 0 + for _, l := range logs { + if nodeFilter != "" && !strings.EqualFold(l.NodeID, nodeFilter) { + continue + } + ui.Print(fmt.Sprintf("[%s] [%s] %s", + l.Timestamp.UTC().Format("2006-01-02 15:04:05 UTC"), + l.NodeID, + l.Message, + )) + printed++ + } + if printed == 0 { + ui.Warning("No logs found") + } + ui.Line() +} + +// ---- shared helpers ---- + +func formatDuration(d time.Duration) string { + if d < 0 { + d = 0 + } + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + if d < time.Minute { + return fmt.Sprintf("%.0fs", d.Seconds()) + } + return fmt.Sprintf("%.1fm", d.Minutes()) +} diff --git a/internal/workflowresolve/ids.go b/internal/workflowresolve/ids.go new file mode 100644 index 00000000..e94b8d41 --- /dev/null +++ b/internal/workflowresolve/ids.go @@ -0,0 +1,47 @@ +package workflowresolve + +// LooksLikeWorkflowID returns true for 64-char hex strings (on-chain WorkflowId). +func LooksLikeWorkflowID(s string) bool { + if len(s) != 64 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +// LooksLikeUUID returns true for the standard UUID shape (8-4-4-4-12). +func LooksLikeUUID(s string) bool { + parts := splitDash(s) + if len(parts) != 5 { + return false + } + lengths := []int{8, 4, 4, 4, 12} + for i, p := range parts { + if len(p) != lengths[i] { + return false + } + } + return true +} + +// LooksLikeOnChainExecutionID returns true for 64-char hex execution ids shown in Explorer. +func LooksLikeOnChainExecutionID(s string) bool { + return LooksLikeWorkflowID(s) +} + +func splitDash(s string) []string { + var parts []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '-' { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + parts = append(parts, s[start:]) + return parts +} diff --git a/internal/workflowresolve/output_format.go b/internal/workflowresolve/output_format.go new file mode 100644 index 00000000..5b56ec82 --- /dev/null +++ b/internal/workflowresolve/output_format.go @@ -0,0 +1,16 @@ +package workflowresolve + +import "fmt" + +const OutputFormatJSON = "json" + +// ResolveOutputFormat normalises --json / --output flags into a validated output format. +func ResolveOutputFormat(outputFormat string, jsonFlag bool) (string, error) { + if jsonFlag { + outputFormat = OutputFormatJSON + } + if outputFormat != "" && outputFormat != OutputFormatJSON { + return "", fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, OutputFormatJSON) + } + return outputFormat, nil +} diff --git a/internal/workflowrender/print.go b/internal/workflowresolve/print.go similarity index 99% rename from internal/workflowrender/print.go rename to internal/workflowresolve/print.go index 5a8c15e7..011f74c1 100644 --- a/internal/workflowrender/print.go +++ b/internal/workflowresolve/print.go @@ -1,4 +1,4 @@ -package workflowrender +package workflowresolve import ( "encoding/json" diff --git a/internal/workflowrender/registry.go b/internal/workflowresolve/registry.go similarity index 98% rename from internal/workflowrender/registry.go rename to internal/workflowresolve/registry.go index dd60719c..5d7756c3 100644 --- a/internal/workflowrender/registry.go +++ b/internal/workflowresolve/registry.go @@ -1,4 +1,4 @@ -// Package workflowrender contains helpers for matching platform workflow +// Package workflowresolve contains helpers for matching platform workflow // rows to registries in the tenant context and rendering them as a table. // It is shared by the workflow list and get commands. // @@ -6,7 +6,7 @@ // "private"), a "contract::<0x…>" tuple for on-chain rows, or // a "grpc:<…>" string for off-chain rows — so direct equality with the // context registry id only works in the first case. -package workflowrender +package workflowresolve import ( "strings" diff --git a/internal/workflowrender/registry_test.go b/internal/workflowresolve/registry_test.go similarity index 99% rename from internal/workflowrender/registry_test.go rename to internal/workflowresolve/registry_test.go index e2ab88fc..366bfc80 100644 --- a/internal/workflowrender/registry_test.go +++ b/internal/workflowresolve/registry_test.go @@ -1,4 +1,4 @@ -package workflowrender +package workflowresolve import ( "testing" diff --git a/internal/workflowresolve/resolve_execution.go b/internal/workflowresolve/resolve_execution.go new file mode 100644 index 00000000..22891539 --- /dev/null +++ b/internal/workflowresolve/resolve_execution.go @@ -0,0 +1,28 @@ +package workflowresolve + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" +) + +// ExecutionLookup resolves executions by on-chain id. +type ExecutionLookup interface { + FindExecutionByOnChainID(ctx context.Context, onChainID string) (*workflowdataclient.Execution, error) +} + +// ResolveExecutionUUID accepts a platform UUID or on-chain execution id and returns the platform UUID. +func ResolveExecutionUUID(ctx context.Context, wdc ExecutionLookup, arg string) (string, error) { + if LooksLikeUUID(arg) { + return arg, nil + } + if LooksLikeOnChainExecutionID(arg) { + exec, err := wdc.FindExecutionByOnChainID(ctx, arg) + if err != nil { + return "", err + } + return exec.UUID, nil + } + return "", fmt.Errorf("%q is not a valid execution UUID or on-chain execution ID", arg) +} diff --git a/internal/workflowresolve/resolve_owner.go b/internal/workflowresolve/resolve_owner.go new file mode 100644 index 00000000..9fcfa6f2 --- /dev/null +++ b/internal/workflowresolve/resolve_owner.go @@ -0,0 +1,31 @@ +package workflowresolve + +import ( + "fmt" + "strings" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// ResolveWorkflowOwnerAddress returns the effective workflow owner for platform +// lookups. For private/off-chain registry deploys the derived workflow owner from +// the runtime context is used. For on-chain deploys the configured +// workflow-owner-address from the selected target is used. +func ResolveWorkflowOwnerAddress( + s *settings.Settings, + resolvedRegistry settings.ResolvedRegistry, + derivedOwner string, +) (string, error) { + if resolvedRegistry != nil && resolvedRegistry.Type() == settings.RegistryTypeOffChain { + owner := strings.TrimSpace(derivedOwner) + if owner == "" { + return "", fmt.Errorf("derived workflow owner is not available; ensure authentication succeeded") + } + return owner, nil + } + + if s == nil { + return "", nil + } + return strings.TrimSpace(s.Workflow.UserWorkflowSettings.WorkflowOwnerAddress), nil +} diff --git a/internal/workflowresolve/resolve_owner_test.go b/internal/workflowresolve/resolve_owner_test.go new file mode 100644 index 00000000..c49cddf8 --- /dev/null +++ b/internal/workflowresolve/resolve_owner_test.go @@ -0,0 +1,41 @@ +package workflowresolve_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" +) + +func TestResolveWorkflowOwnerAddress_OnChain(t *testing.T) { + t.Parallel() + s := &settings.Settings{} + s.Workflow.UserWorkflowSettings.WorkflowOwnerAddress = "0xabc" + + owner, err := workflowresolve.ResolveWorkflowOwnerAddress(s, nil, "") + require.NoError(t, err) + assert.Equal(t, "0xabc", owner) +} + +func TestResolveWorkflowOwnerAddress_OffChainDerived(t *testing.T) { + t.Parallel() + s := &settings.Settings{} + registry := &settings.OffChainRegistry{} + + owner, err := workflowresolve.ResolveWorkflowOwnerAddress(s, registry, "0xderived") + require.NoError(t, err) + assert.Equal(t, "0xderived", owner) +} + +func TestResolveWorkflowOwnerAddress_OffChainMissingDerived(t *testing.T) { + t.Parallel() + s := &settings.Settings{} + registry := &settings.OffChainRegistry{} + + _, err := workflowresolve.ResolveWorkflowOwnerAddress(s, registry, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "derived workflow owner") +} diff --git a/internal/workflowresolve/resolve_test.go b/internal/workflowresolve/resolve_test.go new file mode 100644 index 00000000..809cac73 --- /dev/null +++ b/internal/workflowresolve/resolve_test.go @@ -0,0 +1,80 @@ +package workflowresolve_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" +) + +type stubWorkflowLookup struct { + listAll []workflowdataclient.Workflow + searchByName []workflowdataclient.Workflow +} + +func (s stubWorkflowLookup) ListAll(_ context.Context, _ int) ([]workflowdataclient.Workflow, error) { + return s.listAll, nil +} + +func (s stubWorkflowLookup) SearchByName(_ context.Context, _ string, _ int, _ string) ([]workflowdataclient.Workflow, error) { + return s.searchByName, nil +} + +func TestResolveWorkflowUUID_ByName_SingleActive(t *testing.T) { + t.Parallel() + wdc := stubWorkflowLookup{ + searchByName: []workflowdataclient.Workflow{ + {UUID: "wf-1", Name: "my-wf", Status: "ACTIVE"}, + }, + } + uuid, err := workflowresolve.ResolveWorkflowUUID(context.Background(), wdc, "my-wf", workflowresolve.ResolveOptions{}) + require.NoError(t, err) + assert.Equal(t, "wf-1", uuid) +} + +func TestResolveWorkflowUUID_ByName_MultipleActive(t *testing.T) { + t.Parallel() + wdc := stubWorkflowLookup{ + searchByName: []workflowdataclient.Workflow{ + {UUID: "wf-1", Name: "my-wf", Status: "ACTIVE"}, + {UUID: "wf-2", Name: "my-wf", Status: "ACTIVE"}, + }, + } + _, err := workflowresolve.ResolveWorkflowUUID(context.Background(), wdc, "my-wf", workflowresolve.ResolveOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple ACTIVE") +} + +func TestResolveWorkflowUUID_ByWorkflowID(t *testing.T) { + t.Parallel() + onChainID := "00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856" + wdc := stubWorkflowLookup{ + listAll: []workflowdataclient.Workflow{ + {UUID: "wf-1", WorkflowID: onChainID, Name: "my-wf"}, + }, + } + uuid, err := workflowresolve.ResolveWorkflowUUID(context.Background(), wdc, onChainID, workflowresolve.ResolveOptions{}) + require.NoError(t, err) + assert.Equal(t, "wf-1", uuid) +} + +func TestLooksLikeWorkflowID(t *testing.T) { + t.Parallel() + assert.True(t, workflowresolve.LooksLikeWorkflowID("00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856")) + assert.False(t, workflowresolve.LooksLikeWorkflowID("short")) + assert.False(t, workflowresolve.LooksLikeWorkflowID("00000000-0000-0000-0000-000000000001")) +} + +func TestResolveOutputFormat(t *testing.T) { + t.Parallel() + out, err := workflowresolve.ResolveOutputFormat("", true) + require.NoError(t, err) + assert.Equal(t, workflowresolve.OutputFormatJSON, out) + + _, err = workflowresolve.ResolveOutputFormat("csv", false) + require.Error(t, err) +} diff --git a/internal/workflowresolve/resolve_workflow.go b/internal/workflowresolve/resolve_workflow.go new file mode 100644 index 00000000..6d2c60c3 --- /dev/null +++ b/internal/workflowresolve/resolve_workflow.go @@ -0,0 +1,94 @@ +package workflowresolve + +import ( + "context" + "fmt" + "strings" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// WorkflowLookup lists workflows for name and WorkflowId resolution. +type WorkflowLookup interface { + ListAll(ctx context.Context, pageSize int) ([]workflowdataclient.Workflow, error) + SearchByName(ctx context.Context, name string, pageSize int, ownerAddress string) ([]workflowdataclient.Workflow, error) +} + +// ResolveOptions controls resolution behaviour for ambiguous workflow names. +type ResolveOptions struct { + // WorkflowOwnerAddress optionally scopes name resolution to workflows owned + // by this address (from the selected target's workflow-owner settings). + WorkflowOwnerAddress string + // NonInteractive suppresses warnings when falling back to a non-ACTIVE workflow. + NonInteractive bool +} + +// ResolveWorkflowUUID returns the platform UUID for a workflow name or on-chain WorkflowId. +func ResolveWorkflowUUID(ctx context.Context, wdc WorkflowLookup, arg string, opts ResolveOptions) (string, error) { + if LooksLikeWorkflowID(arg) { + return resolveByWorkflowID(ctx, wdc, arg) + } + return resolveByName(ctx, wdc, arg, opts) +} + +func resolveByWorkflowID(ctx context.Context, wdc WorkflowLookup, workflowID string) (string, error) { + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving workflow ID %q...", workflowID)) + rows, err := wdc.ListAll(ctx, workflowdataclient.DefaultPageSize) + spinner.Stop() + if err != nil { + return "", fmt.Errorf("resolving workflow ID %q: %w", workflowID, err) + } + + for _, r := range rows { + if strings.EqualFold(r.WorkflowID, workflowID) { + if r.UUID == "" { + return "", fmt.Errorf("workflow with ID %q found but has no platform UUID", workflowID) + } + return r.UUID, nil + } + } + return "", fmt.Errorf("no workflow found with ID %q", workflowID) +} + +func resolveByName(ctx context.Context, wdc WorkflowLookup, name string, opts ResolveOptions) (string, error) { + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving workflow %q...", name)) + rows, err := wdc.SearchByName(ctx, name, workflowdataclient.DefaultPageSize, opts.WorkflowOwnerAddress) + spinner.Stop() + if err != nil { + return "", fmt.Errorf("resolving workflow name %q: %w", name, err) + } + + var matches []workflowdataclient.Workflow + for _, r := range rows { + if strings.EqualFold(strings.TrimSpace(r.Name), name) { + matches = append(matches, r) + } + } + if len(matches) == 0 { + return "", fmt.Errorf("no workflow found with name %q", name) + } + + var active []workflowdataclient.Workflow + for _, r := range matches { + if strings.EqualFold(r.Status, "ACTIVE") { + active = append(active, r) + } + } + if len(active) == 1 { + return active[0].UUID, nil + } + if len(active) > 1 { + return "", fmt.Errorf("multiple ACTIVE workflows named %q found; provide the workflow ID instead", name) + } + + if !opts.NonInteractive { + ui.Warning(fmt.Sprintf("No ACTIVE deployment for workflow %q; using the first match (status: %s)", name, matches[0].Status)) + } + if matches[0].UUID == "" { + return "", fmt.Errorf("workflow %q resolved but has no platform UUID", name) + } + return matches[0].UUID, nil +} diff --git a/internal/workflowresolve/workflow_status.go b/internal/workflowresolve/workflow_status.go new file mode 100644 index 00000000..5530af44 --- /dev/null +++ b/internal/workflowresolve/workflow_status.go @@ -0,0 +1,176 @@ +package workflowresolve + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// WorkflowStatusView bundles all data for the status command output. +type WorkflowStatusView struct { + Summary *workflowdataclient.WorkflowSummary + Deployment *workflowdataclient.WorkflowDeploymentRecord + DeploymentErr error + LastExecution *workflowdataclient.Execution + Registries []*tenantctx.Registry +} + +// PrintWorkflowStatusTable renders a rich workflow status view to stdout. +func PrintWorkflowStatusTable(v WorkflowStatusView) { + s := v.Summary + ui.Line() + matched := ResolveWorkflowRegistry(s.WorkflowSource, v.Registries) + regID := RegistryIDOrSource(s.WorkflowSource, matched) + + ui.Bold(fmt.Sprintf("Workflow: %s", s.Name)) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", s.WorkflowID)) + ui.Dim(fmt.Sprintf(" Owner: %s", s.OwnerAddress)) + ui.Dim(fmt.Sprintf(" Status: %s%s", s.Status, deploymentStatusHint(s.Status))) + ui.Dim(fmt.Sprintf(" Registry: %s", regID)) + if matched != nil && RegistryEligibleForContractRows(matched) && matched.Address != nil { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(*matched.Address))) + } else if _, addr, ok := ParseContractWorkflowSource(s.WorkflowSource); ok && strings.TrimSpace(addr) != "" { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(addr))) + } + ui.Dim(fmt.Sprintf(" Registered: %s", s.RegisteredAt.UTC().Format("2006-01-02 15:04:05 UTC"))) + + if s.ExecutedAt != nil { + ui.Dim(fmt.Sprintf(" Last executed: %s", s.ExecutedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) + if s.Status == "PENDING" { + gap := s.ExecutedAt.Sub(s.RegisteredAt) + ui.Dim(fmt.Sprintf(" Activation gap: %s", formatDuration(gap))) + } + } else { + ui.Dim(" Last executed: never") + if s.Status == "PENDING" { + gap := time.Since(s.RegisteredAt) + ui.Dim(fmt.Sprintf(" Pending for: %s", formatDuration(gap))) + } + } + + ui.Line() + ui.Bold("Deployment") + if v.Deployment != nil { + d := v.Deployment + ui.Dim(fmt.Sprintf(" Status: %s", d.Status)) + ui.Dim(fmt.Sprintf(" Deployed at: %s", d.DeployedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) + if d.TxHash != nil && *d.TxHash != "" { + ui.Dim(fmt.Sprintf(" Tx hash: %s", *d.TxHash)) + } + if d.BinaryURL != nil && *d.BinaryURL != "" { + ui.Dim(fmt.Sprintf(" Binary URL: %s", *d.BinaryURL)) + } + if d.ErrorMessage != nil && *d.ErrorMessage != "" { + ui.Dim(fmt.Sprintf(" Error: %s", *d.ErrorMessage)) + } + } else if v.DeploymentErr != nil { + ui.Dim(" Unavailable — see warning above") + } else { + ui.Dim(" No deployment record found") + } + + if v.LastExecution != nil { + e := v.LastExecution + ui.Line() + ui.Bold("Last execution") + ui.Dim(fmt.Sprintf(" ID: %s", e.ID)) + ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) + ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) + if e.FinishedAt != nil { + ui.Dim(fmt.Sprintf(" Duration: %s", formatDuration(e.FinishedAt.Sub(e.StartedAt)))) + } + if len(e.Errors) > 0 { + ui.Dim(" Errors:") + for _, err := range e.Errors { + ui.Dim(fmt.Sprintf(" - %s (x%d)", err.Error, err.Count)) + } + } + ui.Line() + ui.Bold("Debug further:") + ui.Dim(fmt.Sprintf(" cre execution status %s", e.ID)) + ui.Dim(fmt.Sprintf(" cre execution events %s", e.ID)) + ui.Dim(fmt.Sprintf(" cre execution logs %s", e.ID)) + } else if s.Status == "PENDING" { + ui.Line() + ui.Dim(" Workflow has not executed yet — it may still be activating in the DON.") + } + + ui.Line() +} + +// PrintWorkflowStatusJSON marshals the status view as indented JSON to stdout. +func PrintWorkflowStatusJSON(v WorkflowStatusView) error { + s := v.Summary + out := map[string]any{ + "workflow": map[string]any{ + "name": s.Name, + "workflowId": s.WorkflowID, + "ownerAddress": s.OwnerAddress, + "status": s.Status, + "registeredAt": s.RegisteredAt.UTC().Format(time.RFC3339), + "lastExecutedAt": timeOrNil(s.ExecutedAt), + }, + } + + if v.Deployment != nil { + d := v.Deployment + dep := map[string]any{ + "status": d.Status, + "deployedAt": d.DeployedAt.UTC().Format(time.RFC3339), + "txHash": d.TxHash, + "binaryURL": d.BinaryURL, + } + if d.ErrorMessage != nil && *d.ErrorMessage != "" { + dep["errorMessage"] = *d.ErrorMessage + } + out["deployment"] = dep + } + + if v.LastExecution != nil { + e := v.LastExecution + errs := make([]map[string]any, 0, len(e.Errors)) + for _, err := range e.Errors { + errs = append(errs, map[string]any{"error": err.Error, "count": err.Count}) + } + out["lastExecution"] = map[string]any{ + "uuid": e.UUID, + "status": string(e.Status), + "startedAt": e.StartedAt.UTC().Format(time.RFC3339), + "finishedAt": timeOrNil(e.FinishedAt), + "errors": errs, + } + } + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// deploymentStatusHint returns an inline warning for non-healthy states. +func deploymentStatusHint(status string) string { + switch strings.ToUpper(status) { + case "PENDING": + return " ⚠ not yet active in the DON" + case "FAILED": + return " ✗ activation failed" + case "PAUSED": + return " — paused" + default: + return "" + } +} + +func timeOrNil(t *time.Time) any { + if t == nil { + return nil + } + return t.UTC().Format(time.RFC3339) +} diff --git a/test/common.go b/test/common.go index d7a0a21b..6f21133c 100644 --- a/test/common.go +++ b/test/common.go @@ -22,6 +22,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/testutil" ) var ( @@ -71,6 +72,8 @@ type TestConfig struct { } func NewTestConfig(t *testing.T) *TestConfig { + testutil.IsolateCLIHome(t) + uid := "test-" + uuid.New().String() err := os.MkdirAll(fmt.Sprintf("/tmp/%s", uid), 0755) if err != nil { diff --git a/test/contracts/vault_cap_reg.go b/test/contracts/vault_cap_reg.go new file mode 100644 index 00000000..d70d34be --- /dev/null +++ b/test/contracts/vault_cap_reg.go @@ -0,0 +1,92 @@ +package testcontracts + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" + capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" + capreg "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/capabilities_registry_wrapper_v2" + "github.com/smartcontractkit/chainlink-protos/cre/go/values" + "github.com/smartcontractkit/chainlink-testing-framework/seth" +) + +// DeployVaultCapabilitiesRegistry deploys a minimal CapabilitiesRegistry with one vault DON +// for CLI secrets integration tests. +func DeployVaultCapabilitiesRegistry(t *testing.T, sethClient *seth.Client, vaultPublicKeyHex, donFamily string) common.Address { + t.Helper() + + opts := sethClient.NewTXOpts() + addr, _, contract, err := capreg.DeployCapabilitiesRegistry(opts, sethClient.Client, capreg.CapabilitiesRegistryConstructorParams{ + CanAddOneNodeDONs: true, + }) + require.NoError(t, err) + + _, err = sethClient.Decode(contract.AddCapabilities(sethClient.NewTXOpts(), []capreg.CapabilitiesRegistryCapability{ + { + CapabilityId: vaultcommon.CapabilityID, + ConfigurationContract: common.Address{}, + Metadata: []byte{}, + }, + })) + require.NoError(t, err) + + _, err = sethClient.Decode(contract.AddNodeOperators(sethClient.NewTXOpts(), []capreg.CapabilitiesRegistryNodeOperatorParams{ + {Admin: opts.From, Name: "test-op"}, + })) + require.NoError(t, err) + + p2pID := [32]byte{1} + var encKey, csaKey [32]byte + encKey[0] = 1 + csaKey[0] = 2 + var signer [32]byte + copy(signer[:20], opts.From.Bytes()) + + _, err = sethClient.Decode(contract.AddNodes(sethClient.NewTXOpts(), []capreg.CapabilitiesRegistryNodeParams{ + { + NodeOperatorId: 1, + Signer: signer, + P2pId: p2pID, + EncryptionPublicKey: encKey, + CsaKey: csaKey, + CapabilityIds: []string{vaultcommon.CapabilityID}, + }, + })) + require.NoError(t, err) + + cfgBytes := vaultCapabilityConfigBytes(t, vaultPublicKeyHex, 1) + _, err = sethClient.Decode(contract.AddDONs(sethClient.NewTXOpts(), []capreg.CapabilitiesRegistryNewDONParams{ + { + Name: "vault-don", + DonFamilies: []string{donFamily}, + CapabilityConfigurations: []capreg.CapabilitiesRegistryCapabilityConfiguration{ + {CapabilityId: vaultcommon.CapabilityID, Config: cfgBytes}, + }, + Nodes: [][32]byte{p2pID}, + F: 0, + IsPublic: false, + AcceptsWorkflows: false, + }, + })) + require.NoError(t, err) + + return addr +} + +func vaultCapabilityConfigBytes(t *testing.T, publicKey string, threshold int) []byte { + t.Helper() + valueMap, err := values.WrapMap(map[string]any{ + "VaultPublicKey": publicKey, + "Threshold": threshold, + }) + require.NoError(t, err) + raw, err := proto.Marshal(&capabilitiespb.CapabilityConfig{ + DefaultConfig: values.ProtoMap(valueMap), + }) + require.NoError(t, err) + return raw +} diff --git a/test/multi_command_flows/secrets_happy_path.go b/test/multi_command_flows/secrets_happy_path.go index b8ce7f1a..30f065a4 100644 --- a/test/multi_command_flows/secrets_happy_path.go +++ b/test/multi_command_flows/secrets_happy_path.go @@ -27,8 +27,8 @@ import ( "github.com/smartcontractkit/cre-cli/internal/testutil" ) -// Hex-encoded tdh2easy.PublicKey blob returned by the gateway -const vaultPublicKeyHex = "7b2247726f7570223a2250323536222c22475f626172223a22424d704759487a2b33333432596436582f2b6d4971396d5468556c6d2f317355716b51783333343564303373472b2f2f307257494d39795a70454b44566c6c2b616f36586c513743366546452b665472356568785a4f343d222c2248223a22424257546f7638394b546b41505a7566474454504e35626f456d6453305368697975696e3847336e58517774454931536333394453314b41306a595a6576546155476775444d694431746e6e4d686575373177574b57593d222c22484172726179223a5b22424937726649364c646f7654413948676a684b5955516a4744456a5a66374f30774378466c432f2f384e394733464c796247436d6e54734236632b50324c34596a39477548555a4936386d54342b4e77786f794b6261513d222c22424736634369395574317a65433753786b4c442b6247354751505473717463324a7a544b4c726b784d496e4c36484e7658376541324b6167423243447a4b6a6f76783570414c6a74523734537a6c7146543366746662513d222c224245576f7147546d6b47314c31565a53655874345147446a684d4d2b656e7a6b426b7842782b484f72386e39336b51543963594938486f513630356a65504a732f53575866355a714534564e676b4f672f643530395a6b3d222c22424a31552b6e5344783269567a654177475948624e715242564869626b74466b624f4762376158562f3946744c6876314b4250416c3272696e73714171754459504e2f54667870725a6e655259594a2b2f453162536a673d222c224243675a623770424d777732337138577767736e322b6c4d665259343561347576445345715a7559614e2f356e64744970355a492f4a6f454d372b36304a6338735978682b535365364645683052364f57666855706d453d222c2242465a5942524a336d6647695644312b4f4b4e4f374c54355a6f6574515442624a6b464152757143743268492f52757832756b7166794c6c364d71566e55613557336e49726e71506132566d5345755758546d39456f733d222c22424f716b662f356232636c4d314a78615831446d6a76494c4437334f6734566b42732f4b686b6e4d6867435772552f30574a36734e514a6b425462686b4a5535576b48506342626d45786c6362706a49743349494632303d225d7d" +// VaultPublicKeyHex is a hex-encoded tdh2easy.PublicKey blob used in secrets integration tests. +const VaultPublicKeyHex = "7b2247726f7570223a2250323536222c22475f626172223a22424d704759487a2b33333432596436582f2b6d4971396d5468556c6d2f317355716b51783333343564303373472b2f2f307257494d39795a70454b44566c6c2b616f36586c513743366546452b665472356568785a4f343d222c2248223a22424257546f7638394b546b41505a7566474454504e35626f456d6453305368697975696e3847336e58517774454931536333394453314b41306a595a6576546155476775444d694431746e6e4d686575373177574b57593d222c22484172726179223a5b22424937726649364c646f7654413948676a684b5955516a4744456a5a66374f30774378466c432f2f384e394733464c796247436d6e54734236632b50324c34596a39477548555a4936386d54342b4e77786f794b6261513d222c22424736634369395574317a65433753786b4c442b6247354751505473717463324a7a544b4c726b784d496e4c36484e7658376541324b6167423243447a4b6a6f76783570414c6a74523734537a6c7146543366746662513d222c224245576f7147546d6b47314c31565a53655874345147446a684d4d2b656e7a6b426b7842782b484f72386e39336b51543963594938486f513630356a65504a732f53575866355a714534564e676b4f672f643530395a6b3d222c22424a31552b6e5344783269567a654177475948624e715242564869626b74466b624f4762376158562f3946744c6876314b4250416c3272696e73714171754459504e2f54667870725a6e655259594a2b2f453162536a673d222c224243675a623770424d777732337138577767736e322b6c4d665259343561347576445345715a7559614e2f356e64744970355a492f4a6f454d372b36304a6338735978682b535365364645683052364f57666855706d453d222c2242465a5942524a336d6647695644312b4f4b4e4f374c54355a6f6574515442624a6b464152757143743268492f52757832756b7166794c6c364d71566e55613557336e49726e71506132566d5345755758546d39456f733d222c22424f716b662f356232636c4d314a78615831446d6a76494c4437334f6734566b42732f4b686b6e4d6867435772552f30574a36734e514a6b425462686b4a5535576b48506342626d45786c6362706a49743349494632303d225d7d" // getProjectDirectory extracts the project directory from the project root flag func getProjectDirectory(tc TestConfig) string { @@ -43,7 +43,7 @@ func getProjectDirectory(tc TestConfig) string { // RunSecretsHappyPath runs the complete secrets happy path workflow: // Create -> Update -> List -> Delete -func RunSecretsHappyPath(t *testing.T, tc TestConfig, chainName string) { +func RunSecretsHappyPath(t *testing.T, tc TestConfig, chainName string, capRegAddress string) { t.Helper() // Set up environment variables for pre-deployed contracts @@ -68,7 +68,7 @@ func RunSecretsHappyPath(t *testing.T, tc TestConfig, chainName string) { return } if testutil.QueryIsGetTenantConfig(req.Query) { - _ = json.NewEncoder(w).Encode(testutil.MockGetTenantConfigGraphQLPayload()) + _ = json.NewEncoder(w).Encode(testutil.MockGetTenantConfigGraphQLPayloadWithCapReg(capRegAddress)) return } @@ -106,7 +106,7 @@ func RunSecretsHappyPath(t *testing.T, tc TestConfig, chainName string) { var req reqEnvelope _ = json.NewDecoder(r.Body).Decode(&req) - // Handle the new public key fetch. + // Handle the vault master public key fetch and secrets RPC methods. if req.Method == vaulttypes.MethodPublicKeyGet { type pkResult struct { PublicKey string `json:"publicKey"` @@ -115,7 +115,7 @@ func RunSecretsHappyPath(t *testing.T, tc TestConfig, chainName string) { "jsonrpc": "2.0", "id": req.ID, "method": vaulttypes.MethodPublicKeyGet, - "result": pkResult{PublicKey: vaultPublicKeyHex}, + "result": pkResult{PublicKey: VaultPublicKeyHex}, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) diff --git a/test/multi_command_flows/workflow_private_registry.go b/test/multi_command_flows/workflow_private_registry.go index a8a7982b..751da167 100644 --- a/test/multi_command_flows/workflow_private_registry.go +++ b/test/multi_command_flows/workflow_private_registry.go @@ -76,59 +76,6 @@ func CreateTestBearerCredentialsHome(t *testing.T) string { return homeDir } -// realGoCacheEnv returns GOPATH and GOMODCACHE locations outside t.TempDir()-backed HOME dirs. -// Overriding HOME makes Go default GOPATH to $HOME/go; module files are read-only and break TempDir cleanup. -func realGoCacheEnv(t *testing.T) (gopath, gomodcache string) { - t.Helper() - - realHome, err := os.UserHomeDir() - require.NoError(t, err, "failed to get real home dir") - - gopath = os.Getenv("GOPATH") - if gopath == "" { - gopath = filepath.Join(realHome, "go") - } - - gomodcache = os.Getenv("GOMODCACHE") - if gomodcache == "" { - gomodcache = filepath.Join(gopath, "pkg", "mod") - } - - return gopath, gomodcache -} - -// pinGoCacheForTestHome keeps module cache out of temp HOME directories in the test process. -func pinGoCacheForTestHome(t *testing.T) { - t.Helper() - gopath, gomodcache := realGoCacheEnv(t) - t.Setenv("GOPATH", gopath) - t.Setenv("GOMODCACHE", gomodcache) -} - -// cliChildEnv builds subprocess env with isolated HOME for credentials and pinned Go cache paths. -func cliChildEnv(t *testing.T, testHome string) []string { - t.Helper() - gopath, gomodcache := realGoCacheEnv(t) - - childEnv := make([]string, 0, len(os.Environ())+4) - for _, entry := range os.Environ() { - if strings.HasPrefix(entry, "HOME=") || - strings.HasPrefix(entry, "USERPROFILE=") || - strings.HasPrefix(entry, "GOPATH=") || - strings.HasPrefix(entry, "GOMODCACHE=") { - continue - } - childEnv = append(childEnv, entry) - } - childEnv = append(childEnv, - "HOME="+testHome, - "USERPROFILE="+testHome, - "GOPATH="+gopath, - "GOMODCACHE="+gomodcache, - ) - return childEnv -} - func createTestJWT(orgID string) string { return testjwt.CreateTestJWT(orgID) } @@ -297,7 +244,7 @@ func workflowDeployPrivateRegistry(t *testing.T, tc TestConfig) string { cmd := exec.Command(CLIPath, args...) testHome := CreateTestBearerCredentialsHome(t) - cmd.Env = cliChildEnv(t, testHome) + cmd.Env = testutil.CLIChildEnv(t, testHome) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr @@ -456,7 +403,7 @@ func workflowPausePrivateRegistry(t *testing.T, tc TestConfig) string { cmd := exec.Command(CLIPath, args...) testHome := CreateTestBearerCredentialsHome(t) - cmd.Env = cliChildEnv(t, testHome) + cmd.Env = testutil.CLIChildEnv(t, testHome) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr @@ -611,7 +558,7 @@ func workflowActivatePrivateRegistry(t *testing.T, tc TestConfig) string { cmd := exec.Command(CLIPath, args...) testHome := CreateTestBearerCredentialsHome(t) - cmd.Env = cliChildEnv(t, testHome) + cmd.Env = testutil.CLIChildEnv(t, testHome) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr @@ -754,7 +701,7 @@ func workflowDeletePrivateRegistry(t *testing.T, tc TestConfig) string { cmd := exec.Command(CLIPath, args...) testHome := CreateTestBearerCredentialsHome(t) - cmd.Env = cliChildEnv(t, testHome) + cmd.Env = testutil.CLIChildEnv(t, testHome) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr @@ -846,7 +793,7 @@ func RunPrivateRegistryAuthAndSettingsFinalize(t *testing.T, envPath, blankWorkf bearerHome := CreateTestBearerCredentialsHome(t) t.Setenv("HOME", bearerHome) t.Setenv("USERPROFILE", bearerHome) - pinGoCacheForTestHome(t) + testutil.PinGoCacheForTestHome(t) logger := testutil.NewTestLogger() creds, err := credentials.New(logger) diff --git a/test/multi_command_test.go b/test/multi_command_test.go index c316d3b6..62bd8d54 100644 --- a/test/multi_command_test.go +++ b/test/multi_command_test.go @@ -14,6 +14,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" + testcontracts "github.com/smartcontractkit/cre-cli/test/contracts" "github.com/smartcontractkit/cre-cli/test/multi_command_flows" ) @@ -185,6 +186,9 @@ func TestMultiCommandHappyPaths(t *testing.T) { t.Setenv("TESTID_ENV", "testval") t.Setenv("TESTID_ENV_UPDATED", "testval2") + sethClient := testcontracts.NewSethClientWithContracts(t, L, testEthUrl, constants.TestAnvilChainID, SethConfigPath) + capRegAddr := testcontracts.DeployVaultCapabilitiesRegistry(t, sethClient, multi_command_flows.VaultPublicKeyHex, "zone-a") + tc := NewTestConfig(t) // Use linked Address3 + its key @@ -193,7 +197,7 @@ func TestMultiCommandHappyPaths(t *testing.T) { t.Cleanup(tc.Cleanup(t)) // Run secrets happy path workflow - multi_command_flows.RunSecretsHappyPath(t, tc, chainselectors.ANVIL_DEVNET.Name) + multi_command_flows.RunSecretsHappyPath(t, tc, chainselectors.ANVIL_DEVNET.Name, capRegAddr.Hex()) }) // Run Secrets List with Unsigned diff --git a/test/test_project/por_workflow/go.mod b/test/test_project/por_workflow/go.mod index 60597bab..c724455b 100644 --- a/test/test_project/por_workflow/go.mod +++ b/test/test_project/por_workflow/go.mod @@ -44,8 +44,8 @@ require ( go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sys v0.45.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/test/test_project/por_workflow/go.sum b/test/test_project/por_workflow/go.sum index 4f883aed..af60871c 100644 --- a/test/test_project/por_workflow/go.sum +++ b/test/test_project/por_workflow/go.sum @@ -214,12 +214,12 @@ go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKz go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -227,10 +227,10 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=