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
105 changes: 90 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,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)

Expand All @@ -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
}
Expand All @@ -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})
}
Expand All @@ -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()

Expand All @@ -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)
}
}
}

Loading