diff --git a/harnesses/permissions-scan/Dockerfile b/harnesses/permissions-scan/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/permissions-scan/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/permissions-scan/cmd/script/main.go b/harnesses/permissions-scan/cmd/script/main.go new file mode 100644 index 00000000..c5d25b05 --- /dev/null +++ b/harnesses/permissions-scan/cmd/script/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +const probeInterval = 5 * time.Minute + +func main() { + fmt.Println("=== Perp Exit Custody Monitor ===") + fmt.Println("Bench № 123 — worst-case hours to withdraw without operator help.") + fmt.Println() + + arbRPC := os.Getenv("ARB_RPC_URL") + if arbRPC == "" { + arbRPC = "https://arb1.arbitrum.io/rpc" + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("Starting Prometheus metrics server on :2112") + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("Metrics server error: %v\n", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + runLoop(arbRPC, stop) + }() + + <-sigChan + fmt.Println("\nShutting down...") + close(stop) + wg.Wait() +} + +func runLoop(arbRPC string, stop <-chan struct{}) { + tick := time.NewTicker(probeInterval) + defer tick.Stop() + + probe(arbRPC) + + for { + select { + case <-stop: + return + case <-tick.C: + probe(arbRPC) + } + } +} + +func probe(arbRPC string) { + emitStatic() + probeOstium(arbRPC) + probeGains(arbRPC) +} diff --git a/harnesses/permissions-scan/cmd/script/metrics.go b/harnesses/permissions-scan/cmd/script/metrics.go new file mode 100644 index 00000000..e2e484f5 --- /dev/null +++ b/harnesses/permissions-scan/cmd/script/metrics.go @@ -0,0 +1,71 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + worstCaseHoursGauge *prometheus.GaugeVec + settlementAgeSecsGauge *prometheus.GaugeVec + settlementAgeHrsGauge *prometheus.GaugeVec + settlementCallableGauge *prometheus.GaugeVec + lastSettlementIDGauge *prometheus.GaugeVec + epochNumberGauge *prometheus.GaugeVec + epochAgeHrsGauge *prometheus.GaugeVec +) + +func init() { + worstCaseHoursGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_exit_worst_case_hours", + Help: "Worst-case hours to withdraw without operator help. 999999 = no permissionless path.", + }, []string{"venue"}) + prometheus.MustRegister(worstCaseHoursGauge) + + settlementAgeSecsGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_exit_settlement_age_seconds", + Help: "Seconds since last OstiumVault settlement (live RPC).", + }, []string{"venue"}) + prometheus.MustRegister(settlementAgeSecsGauge) + + settlementAgeHrsGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_exit_settlement_age_hours", + Help: "Hours since last OstiumVault settlement (live RPC).", + }, []string{"venue"}) + prometheus.MustRegister(settlementAgeHrsGauge) + + settlementCallableGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_exit_settlement_callable", + Help: "1 if tryNewSettlement() is callable on OstiumVault right now, 0 otherwise.", + }, []string{"venue"}) + prometheus.MustRegister(settlementCallableGauge) + + lastSettlementIDGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_exit_last_settlement_id", + Help: "Most recent settlement ID from OstiumVault (live RPC).", + }, []string{"venue"}) + prometheus.MustRegister(lastSettlementIDGauge) + + epochNumberGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_exit_epoch_number", + Help: "Current gToken epoch number from gains.trade gDAI vault (live RPC).", + }, []string{"venue"}) + prometheus.MustRegister(epochNumberGauge) + + epochAgeHrsGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_exit_epoch_age_hours", + Help: "Hours since the current gains.trade epoch started (live RPC).", + }, []string{"venue"}) + prometheus.MustRegister(epochAgeHrsGauge) +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("OK")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/permissions-scan/cmd/script/probe_gains.go b/harnesses/permissions-scan/cmd/script/probe_gains.go new file mode 100644 index 00000000..026fd9e7 --- /dev/null +++ b/harnesses/permissions-scan/cmd/script/probe_gains.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "time" +) + +// gDAI vault proxy on Arbitrum One (primary probe target per bench methodology). +// gUSDC: 0xd3443ee1e91aF28e5FB858Fbd0D72A63bA8046E0 (backup) +const gainsgDAI = "0xd85E038593d7A098614721EaE955EC2022B9B91B" + +func probeGains(rpc string) { + epoch, err := ethCall(rpc, gainsgDAI, selCurrentEpoch) + if err != nil { + fmt.Printf("[GAINS] currentEpoch error: %v\n", err) + return + } + epochStart, err := ethCall(rpc, gainsgDAI, selCurrentEpochStart) + if err != nil { + fmt.Printf("[GAINS] currentEpochStart error: %v\n", err) + return + } + + now := time.Now().Unix() + ageHours := float64(now-epochStart.Int64()) / 3600 + + epochNumberGauge.WithLabelValues("gains").Set(float64(epoch.Int64())) + epochAgeHrsGauge.WithLabelValues("gains").Set(ageHours) + + fmt.Printf("[GAINS] epoch=%d age=%.1fh\n", epoch.Int64(), ageHours) +} diff --git a/harnesses/permissions-scan/cmd/script/probe_ostium.go b/harnesses/permissions-scan/cmd/script/probe_ostium.go new file mode 100644 index 00000000..6c0614e4 --- /dev/null +++ b/harnesses/permissions-scan/cmd/script/probe_ostium.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "time" +) + +// OstiumVault proxy on Arbitrum One. +// impl: 0x1E20E46C92F0786889462F065BC9DA163AF6020D (verified) +const ostiumVaultProxy = "0x20d419a8e12c45f88fda7c5760bb6923cee27f98" + +func probeOstium(rpc string) { + lastTs, err := ethCall(rpc, ostiumVaultProxy, selLastSettlementTs) + if err != nil { + fmt.Printf("[OSTIUM] lastSettlementTs error: %v\n", err) + return + } + maxInterval, err := ethCall(rpc, ostiumVaultProxy, selMaxSettlementInterval) + if err != nil { + fmt.Printf("[OSTIUM] maxSettlementInterval error: %v\n", err) + return + } + lastID, err := ethCall(rpc, ostiumVaultProxy, selLastSettlementId) + if err != nil { + fmt.Printf("[OSTIUM] lastSettlementId error: %v\n", err) + return + } + + now := time.Now().Unix() + ageSecs := float64(now - lastTs.Int64()) + ageHours := ageSecs / 3600 + callable := 0.0 + if ageSecs >= float64(maxInterval.Int64()) { + callable = 1.0 + } + + settlementAgeSecsGauge.WithLabelValues("ostium").Set(ageSecs) + settlementAgeHrsGauge.WithLabelValues("ostium").Set(ageHours) + settlementCallableGauge.WithLabelValues("ostium").Set(callable) + lastSettlementIDGauge.WithLabelValues("ostium").Set(float64(lastID.Int64())) + + fmt.Printf("[OSTIUM] id=%d age=%.1fh (max=%dh) callable=%.0f\n", + lastID.Int64(), ageHours, maxInterval.Int64()/3600, callable) +} diff --git a/harnesses/permissions-scan/cmd/script/rpc.go b/harnesses/permissions-scan/cmd/script/rpc.go new file mode 100644 index 00000000..fe2de38f --- /dev/null +++ b/harnesses/permissions-scan/cmd/script/rpc.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strings" + "time" +) + +// Precomputed keccak256("fn()")[:4] selectors — verified with `cast sig`. +const ( + selLastSettlementTs = "0x5ae45c26" // lastSettlementTs() + selMaxSettlementInterval = "0xc996a956" // maxSettlementInterval() + selLastSettlementId = "0x39598fae" // lastSettlementId() + selCurrentEpoch = "0x76671808" // currentEpoch() + selCurrentEpochStart = "0x61a8c8c4" // currentEpochStart() +) + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +type rpcResponse struct { + Result string `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func ethCall(rpcURL, contract, selector string) (*big.Int, error) { + req := rpcRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "eth_call", + Params: []any{ + map[string]string{"to": contract, "data": selector}, + "latest", + }, + } + body, _ := json.Marshal(req) + resp, err := httpClient.Post(rpcURL, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + var out rpcResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + if out.Error != nil { + return nil, fmt.Errorf("rpc: %s", out.Error.Message) + } + hex := strings.TrimPrefix(out.Result, "0x") + if hex == "" { + return nil, fmt.Errorf("empty result for %s on %s", selector, contract) + } + n := new(big.Int) + n.SetString(hex, 16) + return n, nil +} diff --git a/harnesses/permissions-scan/go.mod b/harnesses/permissions-scan/go.mod new file mode 100644 index 00000000..4570fce0 --- /dev/null +++ b/harnesses/permissions-scan/go.mod @@ -0,0 +1,18 @@ +module permissions-scan + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/permissions-scan/go.sum b/harnesses/permissions-scan/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/permissions-scan/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=