Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 80 additions & 15 deletions cmd/execute/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]

Expand All @@ -54,6 +75,7 @@ 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")

p := output.NewPrinter(f.IOStreams, cmd)

Expand All @@ -62,14 +84,15 @@ 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, 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")

return cmd
}
Expand All @@ -93,6 +116,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})
}
Expand All @@ -112,7 +144,41 @@ 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, requireVerified bool, p *output.Printer) error {
isTTY := f.IOStreams.IsTerminal()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
Expand All @@ -133,11 +199,10 @@ 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)
}
default:
time.Sleep(50 * time.Millisecond)
}
}
}

215 changes: 215 additions & 0 deletions cmd/execute/status_verified_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
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())
}
}
Loading