From 0c66eaef18ec192c1dcdd2373804116392c0b6a3 Mon Sep 17 00:00:00 2001 From: ylembachar Date: Fri, 27 Feb 2026 23:22:39 +0100 Subject: [PATCH] tests: add live event smoke tests --- go.mod | 1 + go.sum | 2 + tests/event_smoke/.env.example | 16 ++ tests/event_smoke/README.md | 76 +++++++ tests/event_smoke/api_client.go | 182 +++++++++++++++ tests/event_smoke/case_loader.go | 75 ++++++ tests/event_smoke/chain_client.go | 83 +++++++ tests/event_smoke/config.go | 138 ++++++++++++ .../event_smoke/contracts/EventPlayground.sol | 38 ++++ .../scripts/deploy_event_playground.sh | 51 +++++ tests/event_smoke/event_smoke_live_test.go | 121 ++++++++++ tests/event_smoke/runner.go | 149 ++++++++++++ tests/event_smoke/testdata/cases.chiado.json | 213 ++++++++++++++++++ tests/event_smoke/types.go | 75 ++++++ tests/event_smoke/utils.go | 80 +++++++ 15 files changed, 1300 insertions(+) create mode 100644 tests/event_smoke/.env.example create mode 100644 tests/event_smoke/README.md create mode 100644 tests/event_smoke/api_client.go create mode 100644 tests/event_smoke/case_loader.go create mode 100644 tests/event_smoke/chain_client.go create mode 100644 tests/event_smoke/config.go create mode 100644 tests/event_smoke/contracts/EventPlayground.sol create mode 100644 tests/event_smoke/contracts/scripts/deploy_event_playground.sh create mode 100644 tests/event_smoke/event_smoke_live_test.go create mode 100644 tests/event_smoke/runner.go create mode 100644 tests/event_smoke/testdata/cases.chiado.json create mode 100644 tests/event_smoke/types.go create mode 100644 tests/event_smoke/utils.go diff --git a/go.mod b/go.mod index 2ada0de..5a601e5 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/ethereum/go-ethereum v1.15.11 github.com/gin-contrib/cors v1.7.3 github.com/jackc/pgx/v5 v5.7.1 + github.com/joho/godotenv v1.5.1 github.com/libp2p/go-libp2p-pubsub v0.13.1 github.com/prometheus/client_golang v1.22.0 github.com/shutter-network/contracts/v2 v2.0.0-beta.2.0.20250908105003-7e53b1579b04 diff --git a/go.sum b/go.sum index 4e0d886..58a971b 100644 --- a/go.sum +++ b/go.sum @@ -550,6 +550,8 @@ github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPw github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U= github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= diff --git a/tests/event_smoke/.env.example b/tests/event_smoke/.env.example new file mode 100644 index 0000000..1053719 --- /dev/null +++ b/tests/event_smoke/.env.example @@ -0,0 +1,16 @@ +API_BASE_URL=https://shutter-api.chiado.staging.shutter.network/api +RPC_URL=https://rpc.chiadochain.net +PRIVATE_KEY=0xYOUR_PRIVATE_KEY +PLAYGROUND_ADDR=0x0B05BC0BCe48efb0Dd0777C057D87f9Bf66839b4 +DEST_ADDR=0x1111111111111111111111111111111111111111 +FROM_ADDR=0x1111111111111111111111111111111111111111 +TRANSFER_VALUE=2 +TTL=120 +POLL_SECONDS=130 +POLL_INTERVAL=2 +VERBOSE=true +WAIT_REGISTRATION_RECEIPT=false +REGISTRATION_DELAY_SECONDS=2 +MAX_CONSEC_TIMEOUTS=5 +AUTH_HEADER= +FOUNDRY_DISABLE_NIGHTLY_WARNING=1 \ No newline at end of file diff --git a/tests/event_smoke/README.md b/tests/event_smoke/README.md new file mode 100644 index 0000000..13f2280 --- /dev/null +++ b/tests/event_smoke/README.md @@ -0,0 +1,76 @@ +# Event Smoke + +Live smoke tests for event-based identity registration and decryption key generation. + +## Classification + +- This suite is a **smoke test** by intent. +- It is also a **live integration test** by execution model. +- It is intended to run against a **reachable RPC endpoint** and a **running keyper set** (with DKG completed for the target eon). +- It is excluded from default test runs via `//go:build live`. + +## Tooling + +- To run the live tests: `cast`, `openssl` +- To deploy the playground contract with the helper script: `forge` + +## .env support + +The live test auto-loads `.env` from: + +- `tests/event_smoke/.env` + +## Deploy playground contract + +For Chiado, a playground contract is already deployed at: + +`0x0B05BC0BCe48efb0Dd0777C057D87f9Bf66839b4` + +You can reuse it directly: + +```bash +export PLAYGROUND_ADDR=0x0B05BC0BCe48efb0Dd0777C057D87f9Bf66839b4 +``` + +If you want a fresh deployment, run: + +```bash +PRIVATE_KEY=0x... RPC_URL=https://rpc.chiadochain.net \ +./tests/event_smoke/scripts/deploy_event_playground.sh +``` + +Use the returned address as PLAYGROUND_ADDR. + +## Run + +```bash +go test -tags=live ./tests/event_smoke -v +``` + +Run selected cases only: + +```bash +CASES=transfer_like,indexed_dynamic_note_eq go test -tags=live ./tests/event_smoke -v +``` + +## Required env vars + +- `API_BASE_URL` +- `RPC_URL` +- `PRIVATE_KEY` +- `PLAYGROUND_ADDR` +- `DEST_ADDR` + +## Optional env vars + +- `FROM_ADDR` +- `TRANSFER_VALUE` +- `TTL` +- `POLL_SECONDS` +- `POLL_INTERVAL` +- `VERBOSE` +- `WAIT_REGISTRATION_RECEIPT` +- `REGISTRATION_DELAY_SECONDS` +- `MAX_CONSEC_TIMEOUTS` +- `AUTH_HEADER` +- `CASES_FILE` (default: `testdata/cases.chiado.json`) \ No newline at end of file diff --git a/tests/event_smoke/api_client.go b/tests/event_smoke/api_client.go new file mode 100644 index 0000000..2bd1689 --- /dev/null +++ b/tests/event_smoke/api_client.go @@ -0,0 +1,182 @@ +package eventsmoke + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +func compileTrigger(cfg *Config, event string, args []EventArg) (string, error) { + req := compileReq{ + Contract: cfg.PlaygroundAddr, + EventSig: event, + Args: args, + } + + var payload map[string]any + if err := postJSON(cfg, "/event/compile_trigger_definition", req, &payload); err != nil { + return "", err + } + + if v := str(payload["trigger_definition"]); v != "" { + return v, nil + } + if v := str(payload["triggerDefinition"]); v != "" { + return v, nil + } + if msgObj, ok := payload["message"].(map[string]any); ok { + if v := str(msgObj["trigger_definition"]); v != "" { + return v, nil + } + if v := str(msgObj["triggerDefinition"]); v != "" { + return v, nil + } + } + + return "", errors.New(extractErr(payload)) +} + +func registerIdentity(cfg *Config, triggerDef string) (identity string, eon int64, txHash string, prefix string, err error) { + randHex, err := runCmd("openssl", "rand", "-hex", "32") + if err != nil { + return "", 0, "", "", err + } + prefix = "0x" + strings.TrimSpace(randHex) + + req := registerReq{ + TriggerDefinition: triggerDef, + IdentityPrefix: prefix, + TTL: cfg.TTL, + } + + var payload map[string]any + if err := postJSON(cfg, "/event/register_identity", req, &payload); err != nil { + return "", 0, "", "", err + } + + root := payload + if m, ok := payload["message"].(map[string]any); ok { + root = m + } + + identity = str(root["identity"]) + txHash = str(root["tx_hash"]) + eon = toInt64(root["eon"]) + + if identity == "" || txHash == "" || eon == 0 { + return "", 0, "", "", errors.New(extractErr(payload)) + } + return +} + +func getDecryptionKey(cfg *Config, identity string, eon int64) (key, msg string, ok bool) { + u, _ := url.Parse(cfg.APIBase + "/event/get_decryption_key") + q := u.Query() + q.Set("identity", identity) + q.Set("eon", fmt.Sprint(eon)) + u.RawQuery = q.Encode() + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + applyAuthHeader(req, cfg.AuthHeader) + + resp, err := cfg.HTTPClient.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout") { + return "", "api timeout (keyper fallback likely hanging)", false + } + return "", err.Error(), false + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + raw := strings.TrimSpace(string(body)) + logf(cfg, "GET %s status=%d body=%s", u.String(), resp.StatusCode, raw) + + if strings.HasPrefix(raw, "0x") && len(raw) > 2 { + return raw, "", true + } + + var m map[string]any + _ = json.Unmarshal(body, &m) + + if v := str(m["decryption_key"]); strings.HasPrefix(v, "0x") && len(v) > 2 { + return v, "", true + } + if msgObj, ok := m["message"].(map[string]any); ok { + if v := str(msgObj["decryption_key"]); strings.HasPrefix(v, "0x") && len(v) > 2 { + return v, "", true + } + } + + if resp.StatusCode >= 400 { + return "", fmt.Sprintf("http %d: %s", resp.StatusCode, raw), false + } + + e := extractErr(m) + if e == "unknown error" && raw != "" { + e = raw + } + return "", e, false +} + +func postJSON(cfg *Config, path string, body any, out any) error { + reqBytes, _ := json.Marshal(body) + fullURL := cfg.APIBase + path + + req, _ := http.NewRequest(http.MethodPost, fullURL, bytes.NewReader(reqBytes)) + req.Header.Set("Content-Type", "application/json") + applyAuthHeader(req, cfg.AuthHeader) + + logf(cfg, "POST %s body=%s", fullURL, string(reqBytes)) + resp, err := cfg.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respBytes, _ := io.ReadAll(resp.Body) + logf(cfg, "POST %s status=%d body=%s", fullURL, resp.StatusCode, strings.TrimSpace(string(respBytes))) + + _ = json.Unmarshal(respBytes, out) + if resp.StatusCode >= 400 { + return fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBytes))) + } + return nil +} + +func applyAuthHeader(req *http.Request, authHeader string) { + if strings.TrimSpace(authHeader) == "" { + return + } + p := strings.SplitN(authHeader, ":", 2) + if len(p) != 2 { + return + } + req.Header.Set(strings.TrimSpace(p[0]), strings.TrimSpace(p[1])) +} + +func extractErr(m map[string]any) string { + if s := str(m["description"]); s != "" { + return s + } + if s := str(m["error"]); s != "" { + return s + } + if s := str(m["message"]); s != "" { + return s + } + if errs, ok := m["errors"].([]any); ok && len(errs) > 0 { + return fmt.Sprint(errs[0]) + } + return "unknown error" +} \ No newline at end of file diff --git a/tests/event_smoke/case_loader.go b/tests/event_smoke/case_loader.go new file mode 100644 index 0000000..859c6d8 --- /dev/null +++ b/tests/event_smoke/case_loader.go @@ -0,0 +1,75 @@ +package eventsmoke + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strings" +) + +type jsonCase struct { + Name string `json:"name"` + Description string `json:"description"` + EventSig string `json:"eventSig"` + Args []EventArg `json:"args"` + EmitSig string `json:"emitSig"` + EmitArgs []string `json:"emitArgs"` + Expected string `json:"expected"` // "pass" | "fail" +} + +var varRe = regexp.MustCompile(`\$\{([A-Z0-9_]+)\}`) + +func LoadCasesFromJSON(path string, vars map[string]string) ([]TestCase, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read cases file: %w", err) + } + + var raw []jsonCase + if err := json.Unmarshal(b, &raw); err != nil { + return nil, fmt.Errorf("parse cases json: %w", err) + } + + out := make([]TestCase, 0, len(raw)) + for _, c := range raw { + tc := TestCase{ + Name: c.Name, + Description: c.Description, + Event: expand(c.EventSig, vars), + EmitSig: expand(c.EmitSig, vars), + EmitArg: make([]string, 0, len(c.EmitArgs)), + Args: make([]EventArg, 0, len(c.Args)), + ExpectKey: !strings.EqualFold(strings.TrimSpace(c.Expected), "fail"), + } + for _, a := range c.EmitArgs { + tc.EmitArg = append(tc.EmitArg, expand(a, vars)) + } + for _, a := range c.Args { + tc.Args = append(tc.Args, EventArg{ + Name: expand(a.Name, vars), + Op: expand(a.Op, vars), + Number: expand(a.Number, vars), + Bytes: expand(a.Bytes, vars), + }) + } + out = append(out, tc) + } + return out, nil +} + +func expand(s string, vars map[string]string) string { + return varRe.ReplaceAllStringFunc(s, func(m string) string { + sub := varRe.FindStringSubmatch(m) + if len(sub) != 2 { + return m + } + if v, ok := vars[sub[1]]; ok { + return v + } + if v := os.Getenv(sub[1]); v != "" { + return v + } + return m + }) +} \ No newline at end of file diff --git a/tests/event_smoke/chain_client.go b/tests/event_smoke/chain_client.go new file mode 100644 index 0000000..d61b87b --- /dev/null +++ b/tests/event_smoke/chain_client.go @@ -0,0 +1,83 @@ +package eventsmoke + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +func emitEvent(cfg *Config, emitSig string, emitArgs []string) (string, error) { + cmd := []string{ + "send", "--async", "--json", + "--rpc-url", cfg.RPCURL, + "--private-key", cfg.PrivateKey, + cfg.PlaygroundAddr, + emitSig, + } + cmd = append(cmd, emitArgs...) + + out, err := runCmd("cast", cmd...) + if err != nil { + return "", err + } + + var m map[string]any + if json.Unmarshal([]byte(out), &m) == nil { + if h := str(m["transactionHash"]); h != "" { + return h, nil + } + } + if h := txHashRe.FindString(out); h != "" { + return h, nil + } + return "", fmt.Errorf("tx hash not found in cast output: %s", strings.TrimSpace(out)) +} + +func waitReceiptBlock(cfg *Config, txHash string) (int64, error) { + deadline := time.Now().Add(time.Duration(cfg.PollSeconds) * time.Second) + for time.Now().Before(deadline) { + out, err := runCmd("cast", "receipt", "--json", "--rpc-url", cfg.RPCURL, txHash) + if err == nil { + var m map[string]any + if json.Unmarshal([]byte(out), &m) == nil { + if b := str(m["blockNumber"]); b != "" { + if v := parseBlockNumber(b); v > 0 { + return v, nil + } + } + } + } + time.Sleep(time.Duration(cfg.PollInterval) * time.Second) + } + return 0, fmt.Errorf("timeout waiting receipt for tx %s", txHash) +} + +func waitBlockGreater(cfg *Config, target int64) error { + deadline := time.Now().Add(time.Duration(cfg.PollSeconds) * time.Second) + for time.Now().Before(deadline) { + out, err := runCmd("cast", "block-number", "--rpc-url", cfg.RPCURL) + if err == nil { + cur, _ := strconv.ParseInt(strings.TrimSpace(out), 10, 64) + if cur > target { + return nil + } + } + time.Sleep(1 * time.Second) + } + return fmt.Errorf("timeout waiting for block > %d", target) +} + +func parseBlockNumber(s string) int64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + if strings.HasPrefix(s, "0x") { + v, _ := strconv.ParseInt(strings.TrimPrefix(s, "0x"), 16, 64) + return v + } + v, _ := strconv.ParseInt(s, 10, 64) + return v +} \ No newline at end of file diff --git a/tests/event_smoke/config.go b/tests/event_smoke/config.go new file mode 100644 index 0000000..be7f2a5 --- /dev/null +++ b/tests/event_smoke/config.go @@ -0,0 +1,138 @@ +package eventsmoke + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + APIBase string + RPCURL string + PrivateKey string + PlaygroundAddr string + DestAddr string + FromAddr string + TransferValue int + TTL uint64 + PollSeconds int + PollInterval int + AuthHeader string + Verbose bool + WaitRegReceipt bool + RegistrationDelay time.Duration + MaxConsecTimeouts int + HTTPClient *http.Client + CasesFile string +} + +func LoadConfigFromEnv() (*Config, error) { + apiBase, err := mustEnv("API_BASE_URL") + if err != nil { + return nil, err + } + rpcURL, err := mustEnv("RPC_URL") + if err != nil { + return nil, err + } + privateKey, err := mustEnv("PRIVATE_KEY") + if err != nil { + return nil, err + } + playground, err := mustEnv("PLAYGROUND_ADDR") + if err != nil { + return nil, err + } + dest, err := mustEnv("DEST_ADDR") + if err != nil { + return nil, err + } + + transferValue := getInt("TRANSFER_VALUE", 2) + ttl := getUint64("TTL", 120) + pollSeconds := getInt("POLL_SECONDS", 130) + pollInterval := getInt("POLL_INTERVAL", 2) + verbose := getBool("VERBOSE", true) + waitRegReceipt := getBool("WAIT_REGISTRATION_RECEIPT", false) + regDelaySeconds := getInt("REGISTRATION_DELAY_SECONDS", 2) + maxTimeouts := getInt("MAX_CONSEC_TIMEOUTS", 5) + casesFile := getEnv("CASES_FILE", "testdata/cases.chiado.json") + + return &Config{ + APIBase: strings.TrimRight(apiBase, "/"), + RPCURL: rpcURL, + PrivateKey: privateKey, + PlaygroundAddr: playground, + DestAddr: dest, + FromAddr: strings.TrimSpace(os.Getenv("FROM_ADDR")), + TransferValue: transferValue, + TTL: ttl, + PollSeconds: pollSeconds, + PollInterval: pollInterval, + AuthHeader: strings.TrimSpace(os.Getenv("AUTH_HEADER")), + Verbose: verbose, + WaitRegReceipt: waitRegReceipt, + RegistrationDelay: time.Duration(regDelaySeconds) * time.Second, + MaxConsecTimeouts: maxTimeouts, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + CasesFile: casesFile, + }, nil +} + +func mustEnv(k string) (string, error) { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return "", fmt.Errorf("missing required env var %s", k) + } + return v, nil +} + +func getEnv(k, d string) string { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return d + } + return v +} + +func getInt(k string, d int) int { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return d + } + n, err := strconv.Atoi(v) + if err != nil { + return d + } + return n +} + +func getUint64(k string, d uint64) uint64 { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return d + } + n, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return d + } + return n +} + +func getBool(k string, d bool) bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv(k))) + if v == "" { + return d + } + switch v { + case "1", "true", "yes", "y", "on": + return true + case "0", "false", "no", "n", "off": + return false + default: + return d + } +} \ No newline at end of file diff --git a/tests/event_smoke/contracts/EventPlayground.sol b/tests/event_smoke/contracts/EventPlayground.sol new file mode 100644 index 0000000..7f3b4e8 --- /dev/null +++ b/tests/event_smoke/contracts/EventPlayground.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +contract EventPlayground { + event TransferLike(address indexed from, address indexed to, uint256 value); + event StaticArgs(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount); + event DynamicArgs(string note, bytes blob, uint256[] nums); + event IndexedDynamic(string indexed note, bytes indexed blob); + + function emitTransferLike(address from, address to, uint256 value) external { + emit TransferLike(from, to, value); + } + + function emitStaticSample() external { + emit StaticArgs( + 0x1111111111111111111111111111111111111111, + true, + 0xdeadbeef, + bytes32("tag"), + 42 + ); + } + + function emitStaticCustom(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount) external { + emit StaticArgs(user, ok, sig, tag, amount); + } + + function emitDynamicSample() external { + uint256[] memory nums = new uint256[](2); + nums[0] = 1; + nums[1] = 2; + emit DynamicArgs("hello", hex"beef", nums); + } + + function emitIndexedDynamicSample() external { + emit IndexedDynamic("hello", hex"beef"); + } +} \ No newline at end of file diff --git a/tests/event_smoke/contracts/scripts/deploy_event_playground.sh b/tests/event_smoke/contracts/scripts/deploy_event_playground.sh new file mode 100644 index 0000000..1e060c5 --- /dev/null +++ b/tests/event_smoke/contracts/scripts/deploy_event_playground.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +# - set vars here or via env before running +# - run forge create +# - optionally verify via sourcify + +RPC_URL="${RPC_URL:-https://rpc.chiadochain.net}" +PK="${PK:-${PRIVATE_KEY:-}}" +CHAIN="${CHAIN:-chiado}" +VERIFY="${VERIFY:-false}" + +if [[ -z "$PK" ]]; then + echo "set PK or PRIVATE_KEY" >&2 + exit 1 +fi + +contracts_root_dir="$(git rev-parse --show-toplevel)" +cd "$contracts_root_dir" + +CONTRACT_PATH="tests/event_smoke/contracts/EventPlayground.sol" +CONTRACT_FQN="${CONTRACT_PATH}:EventPlayground" + +out="$( + forge create \ + --rpc-url "$RPC_URL" \ + --private-key "$PK" \ + "$CONTRACT_FQN" \ + --broadcast -vvvv +)" + +echo "$out" + +PLAYGROUND_ADDR="$(echo "$out" | awk '/Deployed to:/{print $3}' | tail -n1)" +if [[ -z "$PLAYGROUND_ADDR" ]]; then + echo "could not parse deployed address" >&2 + exit 1 +fi + +if [[ "$VERIFY" == "true" ]]; then + forge verify-contract \ + --root "$contracts_root_dir" \ + --verifier sourcify \ + --chain "$CHAIN" \ + "$PLAYGROUND_ADDR" \ + "$CONTRACT_FQN" +fi + +echo +echo "PLAYGROUND_ADDR=$PLAYGROUND_ADDR" +echo "export PLAYGROUND_ADDR=$PLAYGROUND_ADDR" \ No newline at end of file diff --git a/tests/event_smoke/event_smoke_live_test.go b/tests/event_smoke/event_smoke_live_test.go new file mode 100644 index 0000000..285a454 --- /dev/null +++ b/tests/event_smoke/event_smoke_live_test.go @@ -0,0 +1,121 @@ +//go:build live + +package eventsmoke + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestEventSmokeCases(t *testing.T) { + loadDotEnv() + + cfg, err := LoadConfigFromEnv() + if err != nil { + t.Skipf("live env not configured: %v", err) + } + + from := strings.TrimSpace(cfg.FromAddr) + if from == "" { + from, err = resolveFromAddress(cfg.PrivateKey) + if err != nil { + t.Fatalf("resolve from address: %v", err) + } + } + + vars := map[string]string{ + "FROM_ADDR": from, + "DEST_ADDR": cfg.DestAddr, + "TRANSFER_VALUE": strconv.Itoa(cfg.TransferValue), + "PLAYGROUND_ADDR": cfg.PlaygroundAddr, + "TTL": strconv.FormatUint(cfg.TTL, 10), + } + + allCases, err := LoadCasesFromJSON(cfg.CasesFile, vars) + if err != nil { + t.Fatalf("load cases: %v", err) + } + + cases, err := FilterCases(allCases, os.Getenv("CASES")) + if err != nil { + t.Fatalf("filter cases: %v", err) + } + if len(cases) == 0 { + t.Fatalf("no test cases selected") + } + + logf(cfg, "config api=%s rpc=%s playground=%s ttl=%d poll=%ds/%ds verbose=%t cases=%d", + cfg.APIBase, cfg.RPCURL, cfg.PlaygroundAddr, cfg.TTL, cfg.PollSeconds, cfg.PollInterval, cfg.Verbose, len(cases)) + + for _, tc := range cases { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Logf("%s", tc.Description) + r := runCase(cfg, tc) + if r.Status != "PASS" { + t.Fatalf("%s", r.Reason) + } + t.Logf("pass: %s", r.Reason) + }) + } +} + +func loadDotEnv() { + candidates := []string{ + ".env", + filepath.Join("tests", "integration", "event_smoke", ".env"), + filepath.Join("..", "..", "..", ".env"), + } + + existing := make([]string, 0, len(candidates)) + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + existing = append(existing, p) + } + } + + if len(existing) > 0 { + for _, p := range existing { + _ = loadEnvFile(p) + } + } +} + +func loadEnvFile(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, `"'`) + if key == "" { + continue + } + if _, exists := os.LookupEnv(key); exists { + continue + } + _ = os.Setenv(key, val) + } + return sc.Err() +} \ No newline at end of file diff --git a/tests/event_smoke/runner.go b/tests/event_smoke/runner.go new file mode 100644 index 0000000..fff0314 --- /dev/null +++ b/tests/event_smoke/runner.go @@ -0,0 +1,149 @@ +package eventsmoke + +import ( + "fmt" + "strings" + "time" +) + +type stepMeta struct { + Identity string + Eon int64 + Prefix string + TriggerDef string + RegisterTxHash string + EventTxHash string +} + +func runCase(cfg *Config, tc TestCase) Result { + meta := &stepMeta{} + + fmt.Printf("[%s] compile\n", tc.Name) + td, err := compileTrigger(cfg, tc.Event, tc.Args) + if err != nil { + return Result{tc.Name, "FAIL", "compile: " + err.Error()} + } + meta.TriggerDef = td + logf(cfg, "[%s] trigger=%s", tc.Name, shortHex(td, 26)) + + fmt.Printf("[%s] register\n", tc.Name) + identity, eon, regTx, prefix, err := registerIdentity(cfg, td) + if err != nil { + return Result{tc.Name, "FAIL", "register: " + err.Error()} + } + meta.Identity = identity + meta.Eon = eon + meta.RegisterTxHash = regTx + meta.Prefix = prefix + logf(cfg, "[%s] identity=%s eon=%d prefix=%s regTx=%s", tc.Name, identity, eon, prefix, regTx) + + if cfg.WaitRegReceipt { + fmt.Printf("[%s] wait registration receipt\n", tc.Name) + regBlock, err := waitReceiptBlock(cfg, regTx) + if err != nil { + return Result{tc.Name, "FAIL", "registration receipt: " + err.Error()} + } + _ = waitBlockGreater(cfg, regBlock) + } else { + fmt.Printf("[%s] registration tx=%s (sleep %s)\n", tc.Name, regTx, cfg.RegistrationDelay) + time.Sleep(cfg.RegistrationDelay) + } + + fmt.Printf("[%s] emit\n", tc.Name) + evTx, err := emitEvent(cfg, tc.EmitSig, tc.EmitArg) + if err != nil { + return Result{tc.Name, "FAIL", "emit: " + err.Error()} + } + meta.EventTxHash = evTx + logf(cfg, "[%s] emitTx=%s sig=%s args=%v", tc.Name, evTx, tc.EmitSig, tc.EmitArg) + + evBlock, err := waitReceiptBlock(cfg, evTx) + if err != nil { + return Result{tc.Name, "FAIL", "event receipt: " + err.Error()} + } + logf(cfg, "[%s] eventBlock=%d", tc.Name, evBlock) + + fmt.Printf("[%s] poll key\n", tc.Name) + deadline := time.Now().Add(time.Duration(cfg.PollSeconds) * time.Second) + lastErr := "decryption key not ready" + timeouts := 0 + attempt := 0 + + for time.Now().Before(deadline) { + attempt++ + key, msg, ok := getDecryptionKey(cfg, meta.Identity, meta.Eon) + if ok { + if tc.ExpectKey { + return Result{ + Name: tc.Name, + Status: "PASS", + Reason: fmt.Sprintf("identity=%s eon=%d key=%s", meta.Identity, meta.Eon, shortHex(key, 18)), + } + } + return Result{ + Name: tc.Name, + Status: "FAIL", + Reason: fmt.Sprintf("unexpected key (expected no key): identity=%s eon=%d", meta.Identity, meta.Eon), + } + } + + lastErr = msg + logf(cfg, "[%s] pending attempt=%d msg=%s", tc.Name, attempt, msg) + + if strings.Contains(strings.ToLower(msg), "timeout") { + timeouts++ + if timeouts >= cfg.MaxConsecTimeouts { + return Result{ + Name: tc.Name, + Status: "FAIL", + Reason: fmt.Sprintf("aborted after %d timeouts: %s", timeouts, msg), + } + } + } else { + timeouts = 0 + } + + if isTerminalNotFound(msg) { + time.Sleep(time.Duration(cfg.PollInterval) * time.Second) + continue + } + + if !isTransient(msg) { + return Result{ + Name: tc.Name, + Status: "FAIL", + Reason: fmt.Sprintf("non-transient error: %s", msg), + } + } + + time.Sleep(time.Duration(cfg.PollInterval) * time.Second) + } + + if tc.ExpectKey { + return Result{ + Name: tc.Name, + Status: "FAIL", + Reason: fmt.Sprintf("timeout polling key: identity=%s eon=%d last=%s", meta.Identity, meta.Eon, lastErr), + } + } + return Result{ + Name: tc.Name, + Status: "PASS", + Reason: fmt.Sprintf("timeout with no key (expected no key): identity=%s eon=%d", meta.Identity, meta.Eon), + } +} + +func isTerminalNotFound(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "http 404") || + strings.Contains(m, "doesn't exist") || + strings.Contains(m, "doesnt exist") || + strings.Contains(m, "not found") +} + +func isTransient(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "too early") || + strings.Contains(m, "not ready") || + strings.Contains(m, "timeout") +} \ No newline at end of file diff --git a/tests/event_smoke/testdata/cases.chiado.json b/tests/event_smoke/testdata/cases.chiado.json new file mode 100644 index 0000000..1cd9105 --- /dev/null +++ b/tests/event_smoke/testdata/cases.chiado.json @@ -0,0 +1,213 @@ +[ + { + "name": "transfer_like", + "description": "TransferLike with to == DEST_ADDR and value >= TRANSFER_VALUE", + "eventSig": "event TransferLike(address indexed from, address indexed to, uint256 value)", + "args": [ + { "name": "to", "op": "eq", "bytes": "${DEST_ADDR}" }, + { "name": "value", "op": "gte", "number": "${TRANSFER_VALUE}" } + ], + "emitSig": "emitTransferLike(address,address,uint256)", + "emitArgs": ["${FROM_ADDR}", "${DEST_ADDR}", "${TRANSFER_VALUE}"], + "expected": "pass" + }, + { + "name": "static_args", + "description": "StaticArgs baseline amount >= 42", + "eventSig": "event StaticArgs(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount)", + "args": [ + { "name": "amount", "op": "gte", "number": "42" } + ], + "emitSig": "emitStaticSample()", + "emitArgs": [], + "expected": "pass" + }, + { + "name": "dynamic_args", + "description": "DynamicArgs baseline", + "eventSig": "event DynamicArgs(string note, bytes blob, uint256[] nums)", + "args": [], + "emitSig": "emitDynamicSample()", + "emitArgs": [], + "expected": "pass" + }, + { + "name": "transfer_from_to_eq", + "description": "TransferLike with from == FROM_ADDR and to == DEST_ADDR", + "eventSig": "event TransferLike(address indexed from, address indexed to, uint256 value)", + "args": [ + { "name": "from", "op": "eq", "bytes": "${FROM_ADDR}" }, + { "name": "to", "op": "eq", "bytes": "${DEST_ADDR}" } + ], + "emitSig": "emitTransferLike(address,address,uint256)", + "emitArgs": ["${FROM_ADDR}", "${DEST_ADDR}", "${TRANSFER_VALUE}"], + "expected": "pass" + }, + { + "name": "transfer_value_eq", + "description": "TransferLike with exact value match", + "eventSig": "event TransferLike(address indexed from, address indexed to, uint256 value)", + "args": [ + { "name": "to", "op": "eq", "bytes": "${DEST_ADDR}" }, + { "name": "value", "op": "eq", "number": "${TRANSFER_VALUE}" } + ], + "emitSig": "emitTransferLike(address,address,uint256)", + "emitArgs": ["${FROM_ADDR}", "${DEST_ADDR}", "${TRANSFER_VALUE}"], + "expected": "pass" + }, + { + "name": "transfer_value_gt", + "description": "TransferLike with value > 1", + "eventSig": "event TransferLike(address indexed from, address indexed to, uint256 value)", + "args": [ + { "name": "to", "op": "eq", "bytes": "${DEST_ADDR}" }, + { "name": "value", "op": "gt", "number": "1" } + ], + "emitSig": "emitTransferLike(address,address,uint256)", + "emitArgs": ["${FROM_ADDR}", "${DEST_ADDR}", "${TRANSFER_VALUE}"], + "expected": "pass" + }, + { + "name": "indexed_dynamic", + "description": "IndexedDynamic baseline with no predicates", + "eventSig": "event IndexedDynamic(string indexed note, bytes indexed blob)", + "args": [], + "emitSig": "emitIndexedDynamicSample()", + "emitArgs": [], + "expected": "pass" + }, + { + "name": "indexed_dynamic_note_eq", + "description": "IndexedDynamic with note == keccak256(\"hello\")", + "eventSig": "event IndexedDynamic(string indexed note, bytes indexed blob)", + "args": [ + { "name": "note", "op": "eq", "bytes": "0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8" } + ], + "emitSig": "emitIndexedDynamicSample()", + "emitArgs": [], + "expected": "pass" + }, + { + "name": "indexed_dynamic_blob_eq", + "description": "IndexedDynamic with blob == keccak256(0xbeef)", + "eventSig": "event IndexedDynamic(string indexed note, bytes indexed blob)", + "args": [ + { "name": "blob", "op": "eq", "bytes": "0x50cc9609ed13c878caf0b7ac27b34f56c318680963224914c6ea863d460f8a7f" } + ], + "emitSig": "emitIndexedDynamicSample()", + "emitArgs": [], + "expected": "pass" + }, + { + "name": "indexed_dynamic_note_blob_eq", + "description": "IndexedDynamic with both note and blob predicates", + "eventSig": "event IndexedDynamic(string indexed note, bytes indexed blob)", + "args": [ + { "name": "note", "op": "eq", "bytes": "0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8" }, + { "name": "blob", "op": "eq", "bytes": "0x50cc9609ed13c878caf0b7ac27b34f56c318680963224914c6ea863d460f8a7f" } + ], + "emitSig": "emitIndexedDynamicSample()", + "emitArgs": [], + "expected": "pass" + }, + { + "name": "static_amount_eq", + "description": "StaticArgs with exact amount match", + "eventSig": "event StaticArgs(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount)", + "args": [ + { "name": "amount", "op": "eq", "number": "42" } + ], + "emitSig": "emitStaticSample()", + "emitArgs": [], + "expected": "pass" + }, + { + "name": "static_custom_all_fields_eq", + "description": "StaticArgs custom emit, all fields exact match", + "eventSig": "event StaticArgs(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount)", + "args": [ + { "name": "user", "op": "eq", "bytes": "0x2222222222222222222222222222222222222222" }, + { "name": "ok", "op": "eq", "bytes": "0x00" }, + { "name": "sig", "op": "eq", "bytes": "0x12345678" }, + { "name": "tag", "op": "eq", "bytes": "0x746573742d746167000000000000000000000000000000000000000000000000" }, + { "name": "amount", "op": "eq", "number": "777" } + ], + "emitSig": "emitStaticCustom(address,bool,bytes4,bytes32,uint256)", + "emitArgs": [ + "0x2222222222222222222222222222222222222222", + "false", + "0x12345678", + "0x746573742d746167000000000000000000000000000000000000000000000000", + "777" + ], + "expected": "pass", + "knownIssue": "Currently fails potentially due to bytes32 bug" + }, + { + "name": "static_user_eq", + "description": "StaticArgs sample with user address predicate (non-indexed address)", + "eventSig": "event StaticArgs(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount)", + "args": [ + { "name": "user", "op": "eq", "bytes": "0x1111111111111111111111111111111111111111" } + ], + "emitSig": "emitStaticSample()", + "emitArgs": [], + "expected": "pass" + }, + { + "name": "static_sig_eq", + "description": "StaticArgs sample with bytes4 signature predicate (non-indexed bytes4)", + "eventSig": "event StaticArgs(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount)", + "args": [ + { "name": "sig", "op": "eq", "bytes": "0xdeadbeef" } + ], + "emitSig": "emitStaticSample()", + "emitArgs": [], + "expected": "pass", + "knownIssue": "Currently fails due to issue with bytes4 / issue with unindexed offsets" + }, + { + "name": "static_tag_eq", + "description": "StaticArgs sample with bytes32 tag predicate (padding-sensitive)", + "eventSig": "event StaticArgs(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount)", + "args": [ + { "name": "tag", "op": "eq", "bytes": "0x7461670000000000000000000000000000000000000000000000000000000000" } + ], + "emitSig": "emitStaticSample()", + "emitArgs": [], + "expected": "pass", + "knownIssue": "Currently fails due to issue with unindexed offsets" + }, + { + "name": "static_custom_order_permuted", + "description": "StaticArgs custom emit with same predicates but permuted order (offset/order diagnostic)", + "eventSig": "event StaticArgs(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount)", + "args": [ + { "name": "amount", "op": "eq", "number": "777" }, + { "name": "sig", "op": "eq", "bytes": "0x12345678" }, + { "name": "user", "op": "eq", "bytes": "0x2222222222222222222222222222222222222222" } + ], + "emitSig": "emitStaticCustom(address,bool,bytes4,bytes32,uint256)", + "emitArgs": [ + "0x2222222222222222222222222222222222222222", + "false", + "0x12345678", + "0x746573742d746167000000000000000000000000000000000000000000000000", + "777" + ], + "expected": "pass", + "knownIssue": "Currently fails due to issue with unindexed offsets" + }, + { + "name": "static_sig_wrong_len_compile_fail", + "description": "Compile should fail: bytes4 predicate provided with 3 bytes", + "eventSig": "event StaticArgs(address user, bool ok, bytes4 sig, bytes32 tag, uint256 amount)", + "args": [ + { "name": "sig", "op": "eq", "bytes": "0x123456" } + ], + "emitSig": "emitStaticSample()", + "emitArgs": [], + "expected": "fail", + "knownIssue": "No error thrown at compile time" + } +] \ No newline at end of file diff --git a/tests/event_smoke/types.go b/tests/event_smoke/types.go new file mode 100644 index 0000000..53e06b8 --- /dev/null +++ b/tests/event_smoke/types.go @@ -0,0 +1,75 @@ +package eventsmoke + +import ( + "fmt" + "sort" + "strings" +) + +type EventArg struct { + Name string `json:"name"` + Op string `json:"op"` + Number string `json:"number,omitempty"` + Bytes string `json:"bytes,omitempty"` +} + +type compileReq struct { + Contract string `json:"contract"` + EventSig string `json:"eventSig"` + Args []EventArg `json:"arguments"` +} + +type registerReq struct { + TriggerDefinition string `json:"triggerDefinition"` + IdentityPrefix string `json:"identityPrefix"` + TTL uint64 `json:"ttl"` +} + +type TestCase struct { + Name string + Description string + Event string + Args []EventArg + EmitSig string + EmitArg []string + ExpectKey bool +} + +type Result struct { + Name string + Status string + Reason string +} + +func FilterCases(all []TestCase, filter string) ([]TestCase, error) { + filter = strings.TrimSpace(filter) + if filter == "" { + return all, nil + } + + want := map[string]bool{} + for _, part := range strings.Split(filter, ",") { + k := strings.TrimSpace(part) + if k != "" { + want[k] = true + } + } + + out := make([]TestCase, 0, len(all)) + for _, tc := range all { + if want[tc.Name] { + out = append(out, tc) + delete(want, tc.Name) + } + } + + if len(want) > 0 { + missing := make([]string, 0, len(want)) + for k := range want { + missing = append(missing, k) + } + sort.Strings(missing) + return nil, fmt.Errorf("unknown CASES entries: %s", strings.Join(missing, ",")) + } + return out, nil +} \ No newline at end of file diff --git a/tests/event_smoke/utils.go b/tests/event_smoke/utils.go new file mode 100644 index 0000000..49305f0 --- /dev/null +++ b/tests/event_smoke/utils.go @@ -0,0 +1,80 @@ +package eventsmoke + +import ( + "context" + "errors" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + txHashRe = regexp.MustCompile(`0x[0-9a-fA-F]{64}`) + addrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`) +) + +func runCmd(name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return "", fmt.Errorf("%s %s: timeout", name, strings.Join(args, " ")) + } + if err != nil { + return "", fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), strings.TrimSpace(string(out))) + } + return string(out), nil +} + +func resolveFromAddress(privateKey string) (string, error) { + out, err := runCmd("cast", "wallet", "address", "--private-key", privateKey) + if err != nil { + return "", err + } + addr := addrRe.FindString(out) + if addr == "" { + return "", fmt.Errorf("could not parse address from cast output: %s", strings.TrimSpace(out)) + } + return addr, nil +} + +func logf(cfg *Config, format string, args ...any) { + if !cfg.Verbose { + return + } + ts := time.Now().Format("2006-01-02 15:04:05.000") + fmt.Printf("[%s] %s\n", ts, fmt.Sprintf(format, args...)) +} + +func shortHex(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func str(v any) string { + s, _ := v.(string) + return s +} + +func toInt64(v any) int64 { + switch t := v.(type) { + case float64: + return int64(t) + case int64: + return t + case int: + return int64(t) + case string: + n, _ := strconv.ParseInt(strings.TrimSpace(t), 0, 64) + return n + default: + return 0 + } +} \ No newline at end of file