Skip to content
Draft
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
1 change: 1 addition & 0 deletions cmd/obol/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}
openclawCommand(cfg),
sellCommand(cfg),
buyCommand(cfg),
smokeCommand(cfg),
modelCommand(cfg),
{
Name: "app",
Expand Down
140 changes: 140 additions & 0 deletions cmd/obol/smoke.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package main

import (
"context"
"fmt"
"regexp"
"strings"

"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/erc8004"
"github.com/ethereum/go-ethereum/common"
"github.com/urfave/cli/v3"
)

// smokeTestTag is the default validationResponse tag for smoke-test verdicts;
// it matches the erc8004 smoke-test request-hash domain.
const smokeTestTag = "obol/smoke-test/v1"

// smokeBytes32Re matches a 0x-prefixed bytes32 hex string (the sha256 of the
// committed report.md, or an explicit request-hash override).
var smokeBytes32Re = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`)

// smokeCommand groups the smoke-test agent's operator verbs. v0 carries only
// `calldata`: derive ERC-8004 validationResponse calldata for a finished
// smoke run so the operator can submit it with THEIR OWN wallet — the agent
// and the controller NEVER sign validation transactions (same stance as
// `obol bounty eval calldata`).
func smokeCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "smoke",
Usage: "Smoke-test agent verbs: derive ERC-8004 verdict calldata for a run",
Commands: []*cli.Command{
smokeCalldataCommand(cfg),
},
}
}

// smokeCalldataCommand prints ERC-8004 validationResponse calldata for one
// smoke-test run. The request hash is derived as
// keccak256("obol/smoke-test/v1|<targetBaseURL>|<runId>") unless an explicit
// --request-hash override is given (mirrors `obol bounty eval calldata`).
func smokeCalldataCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "calldata",
Usage: "Print ERC-8004 validationResponse calldata for a smoke run, for YOUR wallet to submit (the agent NEVER signs)",
Flags: []cli.Flag{
&cli.StringFlag{Name: "target", Usage: "[REQUIRED] Smoke target base URL (normalized: trimmed, trailing slashes dropped)", Required: true},
&cli.StringFlag{Name: "run-id", Usage: "[REQUIRED] Run ID from the smoke report (results.json runId)", Required: true},
&cli.StringFlag{Name: "request-hash", Usage: "Explicit validation request hash (bytes32, 0x...) — overrides --target/--run-id derivation"},
&cli.IntFlag{Name: "response", Usage: "[REQUIRED] Verdict score 0-100 (results.json score100; the registry reverts above 100)", Required: true},
&cli.StringFlag{Name: "response-uri", Usage: "Commit-pinned GitHub permalink of the committed report.md"},
&cli.StringFlag{Name: "response-hash", Usage: "sha256 of the committed report.md bytes (0x + 64 hex; results.json reportSha256). Optional, zero allowed"},
&cli.StringFlag{Name: "tag", Usage: "Validation tag", Value: smokeTestTag},
&cli.StringFlag{Name: "network", Usage: "Chain", Value: "base-sepolia"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
res, err := buildSmokeCalldata(smokeCalldataInput{
Target: cmd.String("target"),
RunID: cmd.String("run-id"),
RequestHashOverride: cmd.String("request-hash"),
Response: int(cmd.Int("response")),
ResponseURI: cmd.String("response-uri"),
ResponseHash: cmd.String("response-hash"),
Tag: cmd.String("tag"),
Network: cmd.String("network"),
})
if err != nil {
return err
}
fmt.Printf("Request hash: %s\n", res.RequestHash.Hex())
fmt.Printf("ValidationRegistry (%s): %s\n", cmd.String("network"), res.Registry)
fmt.Printf("Calldata: 0x%x\n", res.Calldata)
fmt.Println("Submit with YOUR wallet (e.g. the agent remote-signer or cast send) — the smoke agent and the controller NEVER sign validation transactions.")
return nil
},
}
}

// smokeCalldataInput carries the raw flag values for one calldata derivation.
type smokeCalldataInput struct {
Target string
RunID string
RequestHashOverride string
Response int
ResponseURI string
ResponseHash string
Tag string
Network string
}

// smokeCalldataResult is the derived submit-ready transaction material.
type smokeCalldataResult struct {
RequestHash common.Hash
Registry string
Calldata []byte
}

// buildSmokeCalldata validates the inputs and packs validationResponse
// calldata via the shared erc8004 encoder. Kept free of CLI plumbing so the
// golden test can pin the exact bytes.
func buildSmokeCalldata(in smokeCalldataInput) (smokeCalldataResult, error) {
if in.Response < 0 || in.Response > erc8004.MaxValidationResponse {
return smokeCalldataResult{}, fmt.Errorf("--response %d out of range 0-%d (the deployed registry reverts above %d; submit results.json score100, not score255)",
in.Response, erc8004.MaxValidationResponse, erc8004.MaxValidationResponse)
}

requestHash := erc8004.SmokeTestRequestHash(in.Target, in.RunID)
if raw := strings.TrimSpace(in.RequestHashOverride); raw != "" {
if !smokeBytes32Re.MatchString(raw) {
return smokeCalldataResult{}, fmt.Errorf("--request-hash %q is not a bytes32 hex string (0x + 64 hex chars)", raw)
}
requestHash = common.HexToHash(raw)
}

responseHash := common.Hash{}
if raw := strings.TrimSpace(in.ResponseHash); raw != "" {
if !smokeBytes32Re.MatchString(raw) {
return smokeCalldataResult{}, fmt.Errorf("--response-hash %q is not a sha256 hex string (0x + 64 hex chars)", raw)
}
responseHash = common.HexToHash(raw)
}

registry, err := erc8004.ValidationRegistryAddress(in.Network)
if err != nil {
return smokeCalldataResult{}, err
}

calldata, err := erc8004.EncodeValidationResponse(
requestHash,
uint8(in.Response),
in.ResponseURI,
responseHash,
in.Tag,
)
if err != nil {
return smokeCalldataResult{}, err
}

return smokeCalldataResult{RequestHash: requestHash, Registry: registry, Calldata: calldata}, nil
}
225 changes: 225 additions & 0 deletions cmd/obol/smoke_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package main

import (
"encoding/hex"
"strings"
"testing"

"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/erc8004"
"github.com/urfave/cli/v3"
)

// ─────────────────────────────────────────────────────────────────────────────
// Command structure (house style: sell_test.go)
// ─────────────────────────────────────────────────────────────────────────────

func testSmokeCommand(t *testing.T) *cli.Command {
t.Helper()
return smokeCommand(&config.Config{})
}

func TestSmokeCalldataCommand_Flags(t *testing.T) {
calldata := findSubcommand(t, testSmokeCommand(t), "calldata")
flags := flagMap(calldata)

requireFlags(t, flags, "target", "run-id", "request-hash", "response", "response-uri", "response-hash", "tag", "network")
assertFlagRequired(t, flags, "target")
assertFlagRequired(t, flags, "run-id")
assertFlagRequired(t, flags, "response")
assertStringDefault(t, flags, "network", "base-sepolia")
assertStringDefault(t, flags, "tag", "obol/smoke-test/v1")

// --request-hash is an optional OVERRIDE (mirrors bounty eval calldata):
// the default derivation comes from --target/--run-id.
if f, ok := flags["request-hash"].(*cli.StringFlag); !ok || f.Required {
t.Errorf("--request-hash must be an optional override (derive via --target/--run-id), got required=%v", ok && f.Required)
}
if f, ok := flags["response-hash"].(*cli.StringFlag); !ok || f.Required {
t.Errorf("--response-hash must be optional (zero responseHash is allowed), got required=%v", ok && f.Required)
}
}

// ─────────────────────────────────────────────────────────────────────────────
// Golden calldata
// ─────────────────────────────────────────────────────────────────────────────

// TestBuildSmokeCalldata_Golden pins the full validationResponse calldata for
// fixed inputs: the 4-byte selector (validationResponse(bytes32,uint8,string,
// bytes32,string) == 0x3d659a96), the derived request hash (the erc8004
// smoke golden vector), and the exact ABI-encoded bytes. Any drift here
// changes what operators submit on-chain, so the hex is hardcoded.
func TestBuildSmokeCalldata_Golden(t *testing.T) {
const (
target = "http://obol.stack:8080"
runID = "20260101T000000Z-ab12cd"
responseURI = "https://github.com/example/obol-smoke-reports/blob/0011223344556677889900112233445566778899/reports/obol.stack-8080/20260101T000000Z-ab12cd.md"
responseHash = "0x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"

goldenRequestHash = "0x2a28aa12a52a28414de4933bbe8d1e52e42828ba08006748f544596823ce7a57"
goldenSelector = "3d659a96"
goldenCalldata = "3d659a96" +
"2a28aa12a52a28414de4933bbe8d1e52e42828ba08006748f544596823ce7a57" +
"0000000000000000000000000000000000000000000000000000000000000054" +
"00000000000000000000000000000000000000000000000000000000000000a0" +
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" +
"0000000000000000000000000000000000000000000000000000000000000160" +
"000000000000000000000000000000000000000000000000000000000000008e" +
"68747470733a2f2f6769746875622e636f6d2f6578616d706c652f6f626f6c2d" +
"736d6f6b652d7265706f7274732f626c6f622f303031313232333334343535363" +
"637373838393930303131323233333434353536363737383839392f7265706f72" +
"74732f6f626f6c2e737461636b2d383038302f3230323630313031543030303030" +
"305a2d6162313263642e6d64000000000000000000000000000000000000" +
"0000000000000000000000000000000000000000000000000000000000000012" +
"6f626f6c2f736d6f6b652d746573742f76310000000000000000000000000000"
)

res, err := buildSmokeCalldata(smokeCalldataInput{
Target: target,
RunID: runID,
Response: 84,
ResponseURI: responseURI,
ResponseHash: responseHash,
Tag: "obol/smoke-test/v1",
Network: "base-sepolia",
})
if err != nil {
t.Fatalf("buildSmokeCalldata: %v", err)
}

if res.RequestHash.Hex() != goldenRequestHash {
t.Errorf("request hash = %s, want %s", res.RequestHash.Hex(), goldenRequestHash)
}
if res.Registry != erc8004.ValidationRegistryV2BaseSepolia {
t.Errorf("registry = %s, want %s", res.Registry, erc8004.ValidationRegistryV2BaseSepolia)
}

got := hex.EncodeToString(res.Calldata)
if !strings.HasPrefix(got, goldenSelector) {
t.Errorf("selector = 0x%s, want 0x%s (validationResponse)", got[:8], goldenSelector)
}
if got != goldenCalldata {
t.Errorf("calldata drifted:\n got 0x%s\nwant 0x%s", got, goldenCalldata)
}

// Round-trip through the shared decoder: every field the operator submits
// must come back exactly.
decoded, err := erc8004.DecodeValidationResponseCalldata(res.Calldata)
if err != nil {
t.Fatalf("DecodeValidationResponseCalldata: %v", err)
}
if decoded.RequestHash.Hex() != goldenRequestHash {
t.Errorf("decoded request hash = %s, want %s", decoded.RequestHash.Hex(), goldenRequestHash)
}
if decoded.Response != 84 {
t.Errorf("decoded response = %d, want 84", decoded.Response)
}
if decoded.ResponseURI != responseURI {
t.Errorf("decoded responseURI = %q, want %q", decoded.ResponseURI, responseURI)
}
if decoded.ResponseHash.Hex() != responseHash {
t.Errorf("decoded responseHash = %s, want %s", decoded.ResponseHash.Hex(), responseHash)
}
if decoded.Tag != "obol/smoke-test/v1" {
t.Errorf("decoded tag = %q, want obol/smoke-test/v1", decoded.Tag)
}
}

// TestBuildSmokeCalldata_RequestHashOverride proves --request-hash wins over
// the --target/--run-id derivation, mirroring bounty eval calldata.
func TestBuildSmokeCalldata_RequestHashOverride(t *testing.T) {
const override = "0x1111111111111111111111111111111111111111111111111111111111111111"

res, err := buildSmokeCalldata(smokeCalldataInput{
Target: "http://obol.stack:8080",
RunID: "20260101T000000Z-ab12cd",
RequestHashOverride: override,
Response: 100,
Network: "base-sepolia",
})
if err != nil {
t.Fatalf("buildSmokeCalldata: %v", err)
}
if res.RequestHash.Hex() != override {
t.Errorf("request hash = %s, want override %s", res.RequestHash.Hex(), override)
}

if _, err := buildSmokeCalldata(smokeCalldataInput{
Target: "http://obol.stack:8080",
RunID: "20260101T000000Z-ab12cd",
RequestHashOverride: "0x1234",
Response: 100,
Network: "base-sepolia",
}); err == nil {
t.Error("expected error for malformed --request-hash override")
}
}

// ─────────────────────────────────────────────────────────────────────────────
// Flag validation
// ─────────────────────────────────────────────────────────────────────────────

func TestBuildSmokeCalldata_RejectsResponseOutOfRange(t *testing.T) {
base := smokeCalldataInput{
Target: "http://obol.stack:8080",
RunID: "20260101T000000Z-ab12cd",
Network: "base-sepolia",
}

for _, response := range []int{-1, 101, 255} {
in := base
in.Response = response
if _, err := buildSmokeCalldata(in); err == nil {
t.Errorf("response %d: expected out-of-range error (registry reverts above %d)", response, erc8004.MaxValidationResponse)
}
}

// Boundary values must pass.
for _, response := range []int{0, 100} {
in := base
in.Response = response
if _, err := buildSmokeCalldata(in); err != nil {
t.Errorf("response %d: unexpected error: %v", response, err)
}
}
}

func TestBuildSmokeCalldata_RejectsMalformedResponseHash(t *testing.T) {
base := smokeCalldataInput{
Target: "http://obol.stack:8080",
RunID: "20260101T000000Z-ab12cd",
Response: 50,
Network: "base-sepolia",
}

for _, malformed := range []string{
"0x1234", // too short
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", // missing 0x
"0x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00aZZ", // non-hex
"0x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a0800", // too long
} {
in := base
in.ResponseHash = malformed
if _, err := buildSmokeCalldata(in); err == nil {
t.Errorf("response hash %q: expected malformed-hash error", malformed)
}
}

// Empty response hash is explicitly allowed (zero responseHash per spec).
in := base
in.ResponseHash = ""
if _, err := buildSmokeCalldata(in); err != nil {
t.Errorf("empty response hash should be allowed (zero hash): %v", err)
}
}

func TestBuildSmokeCalldata_RejectsUnknownNetwork(t *testing.T) {
if _, err := buildSmokeCalldata(smokeCalldataInput{
Target: "http://obol.stack:8080",
RunID: "20260101T000000Z-ab12cd",
Response: 50,
Network: "not-a-chain",
}); err == nil {
t.Error("expected error for a network without a verified validation registry deployment")
}
}
Loading
Loading