diff --git a/cmd/execute/status.go b/cmd/execute/status.go index b2d5a99..8340710 100644 --- a/cmd/execute/status.go +++ b/cmd/execute/status.go @@ -14,15 +14,29 @@ import ( // ExecStatusResponse represents the execution status API response. // Shared by transfer, contract-call and status commands. type ExecStatusResponse struct { - ExecutionID string `json:"executionId"` - Status string `json:"status"` - Type string `json:"type"` - TransactionHash *string `json:"transactionHash"` - TransactionLink *string `json:"transactionLink"` - Result any `json:"result"` - Error *string `json:"error"` - CreatedAt string `json:"createdAt"` - CompletedAt *string `json:"completedAt"` + ExecutionID string `json:"executionId"` + Status string `json:"status"` + Type string `json:"type"` + TransactionHash *string `json:"transactionHash"` + TransactionLink *string `json:"transactionLink"` + Result any `json:"result"` + Error *string `json:"error"` + CreatedAt string `json:"createdAt"` + CompletedAt *string `json:"completedAt"` + Receipts []ExecReceipt `json:"receipts,omitempty"` +} + +// ExecReceipt is a chain-re-fetched proof entry attached to an execution. +// A transactionHash alone proves a transaction was submitted; a receipt with +// verified=true and receiptStatus="success" proves it landed onchain. +type ExecReceipt struct { + Hash string `json:"hash"` + ChainID int64 `json:"chainId"` + Verified bool `json:"verified"` + ReceiptStatus string `json:"receiptStatus"` + BlockNumber *int64 `json:"blockNumber,omitempty"` + GasUsed *string `json:"gasUsed,omitempty"` + VerifiedAt *string `json:"verifiedAt,omitempty"` } func NewStatusCmd(f *cmdutil.Factory) *cobra.Command { @@ -34,12 +48,19 @@ func NewStatusCmd(f *cmdutil.Factory) *cobra.Command { Long: `Show the status of a direct blockchain execution (transfer or contract call). Use --watch to poll until the execution reaches a terminal state. +Use --require-verified to fail unless the execution completed AND every +onchain receipt is chain-verified with receiptStatus "success". A completed +status without receipts exits non-zero: submitted is not the same as landed. + See also: kh r st, kh ex transfer, kh ex cc`, Example: ` # Show execution status kh ex st abc123 # Watch until completion - kh ex st abc123 --watch`, + kh ex st abc123 --watch + + # Gate a script on chain-verified success + kh ex st abc123 --watch --require-verified && ./next-step.sh`, RunE: func(cmd *cobra.Command, args []string) error { executionID := args[0] @@ -54,6 +75,8 @@ See also: kh r st, kh ex transfer, kh ex cc`, host := cmdutil.ResolveHost(cmd, cfg) watch, _ := cmd.Flags().GetBool("watch") + requireVerified, _ := cmd.Flags().GetBool("require-verified") + timeout, _ := cmd.Flags().GetDuration("timeout") p := output.NewPrinter(f.IOStreams, cmd) @@ -62,14 +85,16 @@ See also: kh r st, kh ex transfer, kh ex cc`, if fetchErr != nil { return fetchErr } - return renderExecStatus(p, f, sr) + return renderExecStatusChecked(p, f, sr, requireVerified) } - return watchExecStatus(f, client, host, executionID, p) + return watchExecStatus(f, client, host, executionID, timeout, requireVerified, p) }, } cmd.Flags().Bool("watch", false, "Live-update until complete") + cmd.Flags().Bool("require-verified", false, "Exit non-zero unless completed with chain-verified success receipts") + cmd.Flags().Duration("timeout", 5*time.Minute, "Give up watching after this long") return cmd } @@ -93,6 +118,15 @@ func renderExecStatus(p *output.Printer, f *cmdutil.Factory, sr *ExecStatusRespo if sr.CompletedAt != nil && *sr.CompletedAt != "" { tw.AppendRow(table.Row{"Completed", *sr.CompletedAt}) } + for _, r := range sr.Receipts { + state := r.ReceiptStatus + if r.Verified { + state += ", verified" + } else { + state += ", unverified" + } + tw.AppendRow(table.Row{"Receipt", fmt.Sprintf("%s (%s)", r.Hash, state)}) + } if sr.Error != nil && *sr.Error != "" { tw.AppendRow(table.Row{"Error", *sr.Error}) } @@ -112,8 +146,43 @@ func renderExecStatus(p *output.Printer, f *cmdutil.Factory, sr *ExecStatusRespo return nil } -func watchExecStatus(f *cmdutil.Factory, client *khhttp.Client, host, executionID string, p *output.Printer) error { +// renderExecStatusChecked renders the status and, when requireVerified is set, +// additionally enforces chain-verified success receipts on completed executions. +func renderExecStatusChecked(p *output.Printer, f *cmdutil.Factory, sr *ExecStatusResponse, requireVerified bool) error { + if err := renderExecStatus(p, f, sr); err != nil { + return err + } + if requireVerified { + return verifyExecReceipts(sr) + } + return nil +} + +// verifyExecReceipts fails closed: a completed status only counts as landed +// when at least one receipt exists and every receipt is chain-verified with +// receiptStatus "success". not_found and timeout receipts are treated as +// unproven, not as success. +func verifyExecReceipts(sr *ExecStatusResponse) error { + if sr.Status != "completed" { + return fmt.Errorf("execution %s is %s, not completed", sr.ExecutionID, sr.Status) + } + if len(sr.Receipts) == 0 { + return fmt.Errorf("execution %s completed but has no receipts: submission is proven, landing is not", sr.ExecutionID) + } + for _, r := range sr.Receipts { + if !r.Verified { + return fmt.Errorf("receipt %s is not chain-verified (verified=false)", r.Hash) + } + if r.ReceiptStatus != "success" { + return fmt.Errorf("receipt %s has receiptStatus %q, want \"success\"", r.Hash, r.ReceiptStatus) + } + } + return nil +} + +func watchExecStatus(f *cmdutil.Factory, client *khhttp.Client, host, executionID string, timeout time.Duration, requireVerified bool, p *output.Printer) error { isTTY := f.IOStreams.IsTerminal() + deadline := time.Now().Add(timeout) ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() @@ -133,11 +202,17 @@ func watchExecStatus(f *cmdutil.Factory, client *khhttp.Client, host, executionI if isTTY && !p.IsJSON() { fmt.Fprintln(f.IOStreams.Out) } - return renderExecStatus(p, f, sr) + return renderExecStatusChecked(p, f, sr, requireVerified) + } + + if time.Now().After(deadline) { + return fmt.Errorf("timeout after %s: execution %s still %s", timeout, executionID, sr.Status) } default: + if time.Now().After(deadline) { + return fmt.Errorf("timeout after %s: execution %s still not terminal", timeout, executionID) + } time.Sleep(50 * time.Millisecond) } } } - diff --git a/cmd/execute/status_verified_test.go b/cmd/execute/status_verified_test.go new file mode 100644 index 0000000..44ecc56 --- /dev/null +++ b/cmd/execute/status_verified_test.go @@ -0,0 +1,246 @@ +package execute_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/keeperhub/cli/cmd/execute" + "github.com/keeperhub/cli/pkg/iostreams" +) + +func serveStatus(t *testing.T, resp execute.ExecStatusResponse) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "encode error", http.StatusInternalServerError) + } + })) +} + +func verifiedReceipt(hash string) execute.ExecReceipt { + return execute.ExecReceipt{ + Hash: hash, + ChainID: 84532, + Verified: true, + ReceiptStatus: "success", + } +} + +func TestExecStatusCmd_RequireVerified_PassesWithVerifiedReceipts(t *testing.T) { + txHash := "0xabc" + srv := serveStatus(t, execute.ExecStatusResponse{ + ExecutionID: "exec-verified", + Status: "completed", + TransactionHash: &txHash, + Receipts: []execute.ExecReceipt{verifiedReceipt("0xabc")}, + }) + defer srv.Close() + + ios, buf, _, _ := iostreams.Test() + f := newStatusFactory(ios, srv) + + cmd := execute.NewStatusCmd(f) + cmd.SetArgs([]string{"exec-verified", "--require-verified"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "0xabc") { + t.Errorf("expected receipt hash in output, got: %q", out) + } + if !strings.Contains(out, "verified") { + t.Errorf("expected verified marker in output, got: %q", out) + } +} + +func TestExecStatusCmd_RequireVerified_FailsWhenNoReceipts(t *testing.T) { + srv := serveStatus(t, execute.ExecStatusResponse{ + ExecutionID: "exec-noproof", + Status: "completed", + }) + defer srv.Close() + + ios, _, _, _ := iostreams.Test() + f := newStatusFactory(ios, srv) + + cmd := execute.NewStatusCmd(f) + cmd.SetArgs([]string{"exec-noproof", "--require-verified"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when completed execution has no receipts, got nil") + } + if !strings.Contains(err.Error(), "no receipts") { + t.Errorf("expected 'no receipts' in error, got: %q", err.Error()) + } +} + +func TestExecStatusCmd_RequireVerified_FailsWhenReceiptUnverified(t *testing.T) { + srv := serveStatus(t, execute.ExecStatusResponse{ + ExecutionID: "exec-unverified", + Status: "completed", + Receipts: []execute.ExecReceipt{{ + Hash: "0xdead", + ChainID: 84532, + Verified: false, + ReceiptStatus: "success", + }}, + }) + defer srv.Close() + + ios, _, _, _ := iostreams.Test() + f := newStatusFactory(ios, srv) + + cmd := execute.NewStatusCmd(f) + cmd.SetArgs([]string{"exec-unverified", "--require-verified"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for unverified receipt, got nil") + } + if !strings.Contains(err.Error(), "0xdead") { + t.Errorf("expected offending hash in error, got: %q", err.Error()) + } +} + +func TestExecStatusCmd_RequireVerified_FailsWhenReceiptNotSuccess(t *testing.T) { + for _, rs := range []string{"reverted", "not_found", "timeout", "safe_inner_failure"} { + t.Run(rs, func(t *testing.T) { + srv := serveStatus(t, execute.ExecStatusResponse{ + ExecutionID: "exec-" + rs, + Status: "completed", + Receipts: []execute.ExecReceipt{{ + Hash: "0xbeef", + ChainID: 84532, + Verified: true, + ReceiptStatus: rs, + }}, + }) + defer srv.Close() + + ios, _, _, _ := iostreams.Test() + f := newStatusFactory(ios, srv) + + cmd := execute.NewStatusCmd(f) + cmd.SetArgs([]string{"exec-" + rs, "--require-verified"}) + + err := cmd.Execute() + if err == nil { + t.Fatalf("expected error for receiptStatus=%s, got nil", rs) + } + if !strings.Contains(err.Error(), rs) { + t.Errorf("expected receiptStatus %q in error, got: %q", rs, err.Error()) + } + }) + } +} + +func TestExecStatusCmd_WithoutRequireVerified_CompletedWithoutReceiptsStillPasses(t *testing.T) { + srv := serveStatus(t, execute.ExecStatusResponse{ + ExecutionID: "exec-backcompat", + Status: "completed", + }) + defer srv.Close() + + ios, _, _, _ := iostreams.Test() + f := newStatusFactory(ios, srv) + + cmd := execute.NewStatusCmd(f) + cmd.SetArgs([]string{"exec-backcompat"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected back-compat success without --require-verified, got: %v", err) + } +} + +func TestExecStatusCmd_Watch_RequireVerified_PassesOnVerifiedTerminal(t *testing.T) { + callCount := 0 + txHash := "0xwatched" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + var resp execute.ExecStatusResponse + if callCount >= 2 { + resp = execute.ExecStatusResponse{ + ExecutionID: "exec-watch-verified", + Status: "completed", + TransactionHash: &txHash, + Receipts: []execute.ExecReceipt{verifiedReceipt("0xwatched")}, + } + } else { + resp = execute.ExecStatusResponse{ + ExecutionID: "exec-watch-verified", + Status: "pending", + } + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "encode error", http.StatusInternalServerError) + } + })) + defer srv.Close() + + ios, buf, _, _ := iostreams.Test() + f := newStatusFactory(ios, srv) + + cmd := execute.NewStatusCmd(f) + cmd.SetArgs([]string{"exec-watch-verified", "--watch", "--require-verified"}) + + done := make(chan error, 1) + go func() { + done <- cmd.Execute() + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("command timed out") + } + + if !strings.Contains(buf.String(), "0xwatched") { + t.Errorf("expected receipt hash in output, got: %q", buf.String()) + } +} + +func TestExecStatusCmd_Watch_TimeoutExpires(t *testing.T) { + srv := serveStatus(t, execute.ExecStatusResponse{ + ExecutionID: "exec-stuck", + Status: "pending", + }) + defer srv.Close() + + ios, _, _, _ := iostreams.Test() + f := newStatusFactory(ios, srv) + + cmd := execute.NewStatusCmd(f) + cmd.SetArgs([]string{"exec-stuck", "--watch", "--timeout", "100ms"}) + + done := make(chan error, 1) + go func() { + done <- cmd.Execute() + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !strings.Contains(err.Error(), "timeout") { + t.Errorf("expected 'timeout' in error, got: %q", err.Error()) + } + case <-time.After(10 * time.Second): + t.Fatal("watch did not respect --timeout; command still running") + } +}