Skip to content
Merged
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
22 changes: 22 additions & 0 deletions harnesses/permissions-scan/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
71 changes: 71 additions & 0 deletions harnesses/permissions-scan/cmd/script/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
71 changes: 71 additions & 0 deletions harnesses/permissions-scan/cmd/script/metrics.go
Original file line number Diff line number Diff line change
@@ -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)
}
31 changes: 31 additions & 0 deletions harnesses/permissions-scan/cmd/script/probe_gains.go
Original file line number Diff line number Diff line change
@@ -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)
}
44 changes: 44 additions & 0 deletions harnesses/permissions-scan/cmd/script/probe_ostium.go
Original file line number Diff line number Diff line change
@@ -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)
}
69 changes: 69 additions & 0 deletions harnesses/permissions-scan/cmd/script/rpc.go
Original file line number Diff line number Diff line change
@@ -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
}
18 changes: 18 additions & 0 deletions harnesses/permissions-scan/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
46 changes: 46 additions & 0 deletions harnesses/permissions-scan/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Loading